Left Sidebar Icons customization (#7316)

* custom icons * Icons customization modal added * feat: icons customization * enh: filename validation in upload handler to prevent path traversal * refactor: move custom icon styles to proper stylesheet file and remove redundant whitespace * enh: update icon upload to be uploaded to the meshcentral-data folder * fix: revert changes in .gitignore * enh: hide icon customization link for non-admin users and enforce admin check in web server * fix(webserver): resolve merge conflicts and restore valid upload/login flow * fix(webserver): repair conflict-induced callback/loop closures, restore customFiles login arg, and define undefined vars * ref: add reusable modal helper and component idea docs; enhance sidebar icon customization flow * Make sidebar icon customization user-scoped and permission-aware --------- Co-authored-by: Sammy Ndabo <sammy.ndabo@evoludata.com>

Sammy Ndabo committed Mar 15, 2026 at 04:16 UTC 759764a508617832b6391e4daff3486ef6a7cb12
6 files changed +1217 -107
.gitignore
+1 -1
@@ -312,4 +312,4 @@ __pycache__/
312 # When running mkdocs locally as dev
313 docs/__pycache__/
314 docs/env/
315 -docker-compose.yaml
315 +docker-compose.yaml
\ No newline at end of file
public/js/ui-components.js new
+395
@@ -0,0 +1,395 @@
1 +/**
2 + * Reusable UI Components
3 + * This file contains reusable JavaScript components that can be used across the application
4 + *
5 + * default3.handlebars current state (as of 2026-03-02) contains:
6 + * - 256 `setModalContent(...)` calls
7 + * - 243 `showModal(...)` calls
8 + * each modal set/show pair can be reduced to 1 `openModal(...)` after migration to these components, resulting in a potential reduction of ~200 lines of code in default3.handlebars.
9 + *
10 + * Biggest gain first:
11 + * - Standardize modal invocation through reusable helpers in this file,
12 + * then migrate repeated modal calls in default3.handlebars.
13 + * - Expected code reduction in default3.handlebars, and ensures lower duplication risk.
14 + *
15 + * More UI components can be added here, or moved to a dedicated components directory over time (one component per file) as needed.
16 + */
17 +
18 +// Modern Modal Component
19 +class ModernModal {
20 + constructor(modalId, options = {}) {
21 + this.modalId = modalId;
22 + this.options = {
23 + size: 'medium',
24 + showCloseButton: true,
25 + backdrop: true,
26 + keyboard: true,
27 + ...options
28 + };
29 + }
30 +
31 + show(title, content, okCallback = null, okButtonText = 'OK') {
32 + const sizeClass = this.options.size === 'large' ? 'modal-lg' :
33 + this.options.size === 'extra-large' ? 'modal-xl' : '';
34 +
35 + let modalContent = `
36 + <div class="modal-dialog modal-dialog-centered ${sizeClass}">
37 + <div class="modal-content">
38 + <div class="modal-header">
39 + <h5 class="modal-title">${title}</h5>
40 + ${this.options.showCloseButton ? '<button type="button" class="btn-close" data-bs-dismiss="modal"></button>' : ''}
41 + </div>
42 + <div class="modal-body">
43 + ${content}
44 + </div>
45 + <div class="modal-footer">
46 + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
47 + ${okCallback ? `<button type="button" class="btn btn-primary" id="${this.modalId}OkBtn">${okButtonText}</button>` : ''}
48 + </div>
49 + </div>
50 + </div>
51 + `;
52 +
53 + setModalContent(this.modalId, title, content, this.options.size);
54 +
55 + if (okCallback) {
56 + showModal(this.modalId, `${this.modalId}OkBtn`, okCallback);
57 + } else {
58 + showModal(this.modalId);
59 + }
60 + }
61 +
62 + hide() {
63 + const modalElement = document.getElementById(this.modalId);
64 + if (modalElement) {
65 + const modal = bootstrap.Modal.getInstance(modalElement);
66 + if (modal) {
67 + modal.hide();
68 + }
69 + }
70 + }
71 +}
72 +
73 +// Modern Card Component
74 +class ModernCard {
75 + constructor(container, options = {}) {
76 + this.container = container;
77 + this.options = {
78 + title: '',
79 + icon: '',
80 + status: 'default', // default, success, warning, danger
81 + actions: [],
82 + ...options
83 + };
84 + }
85 +
86 + render() {
87 + const statusClasses = {
88 + default: '',
89 + success: 'border-success',
90 + warning: 'border-warning',
91 + danger: 'border-danger'
92 + };
93 +
94 + const statusIcons = {
95 + default: 'fa-circle',
96 + success: 'fa-check-circle',
97 + warning: 'fa-exclamation-circle',
98 + danger: 'fa-times-circle'
99 + };
100 +
101 + const statusColors = {
102 + default: 'text-muted',
103 + success: 'text-success',
104 + warning: 'text-warning',
105 + danger: 'text-danger'
106 + };
107 +
108 + let cardHTML = `
109 + <div class="card modern-card ${statusClasses[this.options.status]} h-100">
110 + <div class="card-header d-flex align-items-center">
111 + <div class="bg-light rounded-circle p-2 me-3">
112 + <i class="fas ${this.options.icon} fa-lg text-secondary"></i>
113 + </div>
114 + <div class="flex-grow-1">
115 + <h6 class="card-title mb-1">${this.options.title}</h6>
116 + <small class="status-badge ${statusColors[this.options.status]}">
117 + <i class="fas ${statusIcons[this.options.status]} me-1"></i>
118 + <span class="status-text">${this.options.status}</span>
119 + </small>
120 + </div>
121 + </div>
122 + <div class="card-body">
123 + <div class="card-content">
124 + ${this.options.content || ''}
125 + </div>
126 + </div>
127 + `;
128 +
129 + if (this.options.actions.length > 0) {
130 + cardHTML += '<div class="card-footer">';
131 + this.options.actions.forEach(action => {
132 + cardHTML += `<button class="btn btn-sm ${action.class || 'btn-primary'}" onclick="${action.onclick}">${action.label}</button>`;
133 + });
134 + cardHTML += '</div>';
135 + }
136 +
137 + cardHTML += '</div>';
138 +
139 + this.container.innerHTML = cardHTML;
140 + }
141 +
142 + updateStatus(status) {
143 + this.options.status = status;
144 + const card = this.container.querySelector('.modern-card');
145 + const statusText = this.container.querySelector('.status-text');
146 + const statusIcon = this.container.querySelector('.status-badge i');
147 +
148 + // Remove all status classes
149 + card.classList.remove('border-success', 'border-warning', 'border-danger');
150 + statusText.classList.remove('text-muted', 'text-success', 'text-warning', 'text-danger');
151 +
152 + // Add new status classes
153 + const statusClasses = {
154 + default: '',
155 + success: 'border-success',
156 + warning: 'border-warning',
157 + danger: 'border-danger'
158 + };
159 +
160 + const statusIcons = {
161 + default: 'fa-circle',
162 + success: 'fa-check-circle',
163 + warning: 'fa-exclamation-circle',
164 + danger: 'fa-times-circle'
165 + };
166 +
167 + const statusColors = {
168 + default: 'text-muted',
169 + success: 'text-success',
170 + warning: 'text-warning',
171 + danger: 'text-danger'
172 + };
173 +
174 + card.classList.add(statusClasses[status]);
175 + statusText.classList.add(statusColors[status]);
176 + statusIcon.className = `fas ${statusIcons[status]} me-1`;
177 + statusText.textContent = status;
178 + }
179 +}
180 +
181 +// Icon Upload Component
182 +// Reusable for any icon-upload card by passing callbacks/options:
183 +// - `onUpload`, `onUrlInput`, `onRemove` for feature-specific behavior
184 +// - `normalizePreviewUrl` for domain/path normalization
185 +// - `iconKey`, `label`, `currentValue` for per-instance identity and content
186 +// The component owns input/file/preview UI; persistence and status updates stay in page logic.
187 +class IconUploadComponent {
188 + constructor(iconKey, container, options = {}) {
189 + this.iconKey = iconKey;
190 + this.container = container;
191 + this.options = {
192 + label: iconKey,
193 + currentValue: '',
194 + onUpload: null,
195 + onRemove: null,
196 + onUrlInput: null,
197 + normalizePreviewUrl: null,
198 + ...options
199 + };
200 + }
201 +
202 + getPreviewSrc(value) {
203 + if ((typeof value !== 'string') || (value.length === 0)) { return ''; }
204 + if (typeof this.options.normalizePreviewUrl !== 'function') { return value; }
205 + try { return this.options.normalizePreviewUrl(value); } catch (ex) { return value; }
206 + }
207 +
208 + render() {
209 + const hasIcon = this.options.currentValue.length > 0;
210 + const initialPreviewSrc = hasIcon ? this.getPreviewSrc(this.options.currentValue) : '';
211 +
212 + const html = `
213 + <div class="icon-upload-component" data-icon-key="${this.iconKey}">
214 + <div class="input-group mb-3">
215 + <input type="text" class="form-control" id="iconInput_${this.iconKey}"
216 + value="${this.options.currentValue}"
217 + placeholder="Enter URL or data URL for ${this.options.label} icon"
218 + oninput="window.iconUploadComponents['${this.iconKey}'].handleUrlInput(this)" />
219 + <button class="btn btn-outline-primary" type="button" onclick="window.iconUploadComponents['${this.iconKey}'].triggerFileUpload()">
220 + <i class="fas fa-upload me-2"></i>Upload
221 + </button>
222 + </div>
223 +
224 + <div class="icon-preview-container ${hasIcon ? '' : 'd-none'}" id="preview_container_${this.iconKey}">
225 + <small class="text-muted me-2">Preview:</small>
226 + <img class="icon-preview-item" id="preview_${this.iconKey}"
227 + src="${initialPreviewSrc}" alt="Icon preview" />
228 + <button class="btn btn-sm btn-outline-danger ms-auto" type="button"
229 + onclick="window.iconUploadComponents['${this.iconKey}'].removeIcon()">
230 + <i class="fas fa-times me-1"></i>Default icon
231 + </button>
232 + </div>
233 +
234 + <input type="file" class="d-none" accept=".svg,.png,image/svg+xml,image/png"
235 + id="iconFile_${this.iconKey}"
236 + onchange="window.iconUploadComponents['${this.iconKey}'].handleFileUpload(this)" />
237 + </div>
238 + `;
239 +
240 + this.container.innerHTML = html;
241 +
242 + // Store reference for global access
243 + if (!window.iconUploadComponents) {
244 + window.iconUploadComponents = {};
245 + }
246 + window.iconUploadComponents[this.iconKey] = this;
247 + }
248 +
249 + triggerFileUpload() {
250 + const fileInput = document.getElementById(`iconFile_${this.iconKey}`);
251 + if (fileInput) {
252 + fileInput.click();
253 + }
254 + }
255 +
256 + handleUrlInput(input) {
257 + const value = input.value.trim();
258 + const previewContainer = document.getElementById(`preview_container_${this.iconKey}`);
259 + const previewIcon = document.getElementById(`preview_${this.iconKey}`);
260 +
261 + if (value.length > 0) {
262 + previewContainer.classList.remove('d-none');
263 + if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.src = this.getPreviewSrc(value); }
264 + else { previewIcon.style.backgroundImage = `url('${value}')`; }
265 + } else {
266 + previewContainer.classList.add('d-none');
267 + if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.removeAttribute('src'); }
268 + else { previewIcon.style.backgroundImage = ''; }
269 + }
270 +
271 + if (this.options.onUrlInput) {
272 + this.options.onUrlInput(this.iconKey, value);
273 + }
274 + }
275 +
276 + async handleFileUpload(input) {
277 + if (!input || !input.files || (input.files.length === 0)) {
278 + return;
279 + }
280 +
281 + const button = this.container.querySelector('.btn-outline-primary');
282 + const originalContent = button.innerHTML;
283 +
284 + // Show loading state
285 + button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Uploading...';
286 + button.disabled = true;
287 +
288 + try {
289 + if (this.options.onUpload) {
290 + const result = await this.options.onUpload(this.iconKey, input.files[0]);
291 +
292 + // Show success state
293 + button.innerHTML = '<i class="fas fa-check me-2"></i>Success!';
294 + button.classList.remove('btn-outline-primary');
295 + button.classList.add('btn-success');
296 +
297 + // Update preview
298 + const previewContainer = document.getElementById(`preview_container_${this.iconKey}`);
299 + const previewIcon = document.getElementById(`preview_${this.iconKey}`);
300 + const textInput = document.getElementById(`iconInput_${this.iconKey}`);
301 +
302 + if (result && result.path) {
303 + previewContainer.classList.remove('d-none');
304 + if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.src = this.getPreviewSrc(result.path); }
305 + else { previewIcon.style.backgroundImage = `url('${result.path}')`; }
306 + textInput.value = result.path;
307 + }
308 +
309 + setTimeout(() => {
310 + button.innerHTML = originalContent;
311 + button.classList.remove('btn-success');
312 + button.classList.add('btn-outline-primary');
313 + button.disabled = false;
314 + }, 2000);
315 + }
316 + } catch (error) {
317 + // Show error state
318 + button.innerHTML = '<i class="fas fa-exclamation-triangle me-2"></i>Failed';
319 + button.classList.remove('btn-outline-primary');
320 + button.classList.add('btn-danger');
321 +
322 + setTimeout(() => {
323 + button.innerHTML = originalContent;
324 + button.classList.remove('btn-danger');
325 + button.classList.add('btn-outline-primary');
326 + button.disabled = false;
327 + }, 2000);
328 + }
329 +
330 + input.value = '';
331 + }
332 +
333 + removeIcon() {
334 + const previewContainer = document.getElementById(`preview_container_${this.iconKey}`);
335 + const previewIcon = document.getElementById(`preview_${this.iconKey}`);
336 + const textInput = document.getElementById(`iconInput_${this.iconKey}`);
337 +
338 + previewContainer.classList.add('d-none');
339 + if (previewIcon.tagName.toLowerCase() === 'img') { previewIcon.removeAttribute('src'); }
340 + else { previewIcon.style.backgroundImage = ''; }
341 + textInput.value = '';
342 + if (this.options.onUrlInput) {
343 + this.options.onUrlInput(this.iconKey, '');
344 + }
345 +
346 + if (this.options.onRemove) {
347 + this.options.onRemove(this.iconKey);
348 + }
349 + }
350 +}
351 +
352 +// Utility functions
353 +function createModernModal(modalId, options = {}) {
354 + return new ModernModal(modalId, options);
355 +}
356 +
357 +function createModernCard(container, options = {}) {
358 + const card = new ModernCard(container, options);
359 + card.render();
360 + return card;
361 +}
362 +
363 +function openModal(options = {}) {
364 + const {
365 + modalId = 'xxAddAgent',
366 + title = '',
367 + body = '',
368 + size = null,
369 + okButtonId = 'idx_dlgOkButton',
370 + onOk = null,
371 + b = null,
372 + tag = null
373 + } = options;
374 +
375 + setModalContent(modalId, title, body, size);
376 + showModal(`${modalId}Modal`, okButtonId, onOk, b, tag);
377 +}
378 +
379 +function createIconUploadComponent(iconKey, container, options = {}) {
380 + const component = new IconUploadComponent(iconKey, container, options);
381 + component.render();
382 + return component;
383 +}
384 +
385 +// Export for use in other modules
386 +if (typeof module !== 'undefined' && module.exports) {
387 + module.exports = {
388 + ModernModal,
389 + ModernCard,
390 + IconUploadComponent,
391 + createModernModal,
392 + createModernCard,
393 + createIconUploadComponent
394 + };
395 +}
public/styles/style-bootstrap.css
+22 -8
@@ -428,7 +428,7 @@ body {
428 padding-top: 5px;
429 cursor: pointer;
430 position: absolute;
431 - right: 0;
431 + right: 0;
432 margin-right: 10px;
433 }
434
@@ -504,7 +504,7 @@ body {
504 -ms-box-sizing:border-box;
505 -moz-box-sizing:border-box;
506 box-sizing:border-box;
507 - -webkit-box-sizing:border-box;
507 + -webkit-box-sizing:border-box;
508 }
509
510 .night #column_l {
@@ -732,7 +732,7 @@ body {
732 padding: 3px;
733 margin-right: 3px;
734 cursor: pointer;
735 - background-color: white;
735 + background-color: white;
736 }
737
738 #id_dialogtitle {
@@ -931,7 +931,7 @@ body {
931 margin-left: 5px;
932 }
933
934 -/* Example if <table> is relplaced with <div><p> then image can be defined in css
934 +/* Example if <table> is relplaced with <div><p> then image can be defined in css
935 #NoMeshesPanel {
936 background: url(../images/info.png) no-repeat 23px 20px;
937 height: 48px;
@@ -2252,7 +2252,7 @@ nav .lbbuttonsel2 {
2252 #d2notifyMsg,
2253 #d2devNotes,
2254 #d2devEvent,
2255 -#d2runcmd,
2255 +#d2runcmd,
2256 #d2devMessage,
2257 #d2smsText,
2258 #d2emailSubject,
@@ -2834,7 +2834,7 @@ body:not(.fullscreen) .notifiyBox {
2834 .deskToolsBar:hover {
2835 background-color: #EFE8B6;
2836 }
2837 -
2837 +
2838 .night .deskToolsBar {
2839 color: #ddd;
2840 }
@@ -3512,6 +3512,20 @@ body:not(.fullscreen) .notifiyBox {
3512 border: none;
3513 }
3514
3515 +/* Shared styles for all custom icons */
3516 +.custom-icon svg {
3517 + background-repeat: no-repeat;
3518 + background-position: center;
3519 + background-size: contain;
3520 + width: 1em !important;
3521 + height: 1em !important;
3522 + display: inline-block !important;
3523 +}
3524 +
3525 +.custom-icon svg path {
3526 + display: none !important;
3527 +}
3528 +
3529 /* hide .sidebar when on mobile */
3530 @media (max-width: 768px) {
3531 #page_leftbar {
@@ -3648,11 +3662,11 @@ body:not(.fullscreen) .notifiyBox {
3662 /* .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered {
3663 display: inline;
3664 }
3651 -
3665 +
3666 .select2-container--bootstrap-5 .select2-selection--multiple .select2-selection__rendered .select2-selection__choice {
3667 display: inline-flex;
3668 }
3655 -
3669 +
3670 .select2-container--bootstrap-5 .select2-selection--multiple .select2-search {
3671 display: inline !important;
3672 } */
public/styles/style.css
+180 -4
@@ -270,7 +270,7 @@ body {
270 padding-top: 5px;
271 cursor: pointer;
272 position: absolute;
273 - right: 0;
273 + right: 0;
274 margin-right: 10px;
275 }
276
@@ -346,7 +346,7 @@ body {
346 -ms-box-sizing:border-box;
347 -moz-box-sizing:border-box;
348 box-sizing:border-box;
349 - -webkit-box-sizing:border-box;
349 + -webkit-box-sizing:border-box;
350 }
351
352 .night #column_l {
@@ -555,6 +555,182 @@ body {
555 cursor: move;
556 }
557
558 +/* Reusable UI Components */
559 +
560 +/* Modern Card Component */
561 +.modern-card {
562 + border: 1px solid var(--bs-border-color);
563 + border-radius: var(--bs-border-radius-lg);
564 + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
565 + transition: all 0.2s ease-in-out;
566 +}
567 +
568 +.modern-card:hover {
569 + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
570 + transform: translateY(-2px);
571 +}
572 +
573 +.modern-card.border-success {
574 + border-color: var(--bs-success);
575 + background-color: var(--bs-success-bg-subtle);
576 +}
577 +
578 +.modern-card.border-warning {
579 + border-color: var(--bs-warning);
580 + background-color: var(--bs-warning-bg-subtle);
581 +}
582 +
583 +/* Modern Icon Preview Component */
584 +.icon-preview-container {
585 + display: flex;
586 + align-items: center;
587 + padding: 0.5rem;
588 + background-color: var(--bs-light);
589 + border-radius: var(--bs-border-radius);
590 + transition: all 0.2s ease-in-out;
591 +}
592 +
593 +.icon-preview-item {
594 + width: 32px;
595 + height: 32px;
596 + background-color: var(--bs-white);
597 + border: 1px solid var(--bs-border-color);
598 + border-radius: var(--bs-border-radius-sm);
599 + background-size: contain;
600 + background-repeat: no-repeat;
601 + background-position: center;
602 + margin-right: 0.5rem;
603 + object-fit: contain;
604 + display: block;
605 + flex: 0 0 32px;
606 +}
607 +
608 +/* Modern Input Group Component */
609 +.modern-input-group .form-control {
610 + border-right: none;
611 +}
612 +
613 +.modern-input-group .btn {
614 + border-left: none;
615 +}
616 +
617 +.modern-input-group .form-control:focus {
618 + border-color: var(--bs-primary);
619 + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
620 +}
621 +
622 +/* Status Badge Component */
623 +.status-badge {
624 + display: inline-flex;
625 + align-items: center;
626 + font-size: 0.875rem;
627 + font-weight: 500;
628 +}
629 +
630 +.status-badge.text-success {
631 + color: var(--bs-success-text-emphasis) !important;
632 +}
633 +
634 +.status-badge.text-warning {
635 + color: var(--bs-warning-text-emphasis) !important;
636 +}
637 +
638 +.status-badge.text-muted {
639 + color: var(--bs-secondary-text-emphasis) !important;
640 +}
641 +
642 +/* Loading States */
643 +.loading-spinner {
644 + display: inline-block;
645 + width: 1rem;
646 + height: 1rem;
647 + border: 0.125em solid currentColor;
648 + border-right-color: transparent;
649 + border-radius: 50%;
650 + animation: spinner-border 0.75s linear infinite;
651 +}
652 +
653 +/* Modern Icons Customization Modal Styles */
654 +.icon-customization-modal .modal-content {
655 + border: none;
656 + border-radius: var(--bs-border-radius-lg);
657 + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175);
658 +}
659 +
660 +.icon-customization-modal .modal-header {
661 + background: linear-gradient(135deg, var(--bs-primary) 0%, var(--bs-purple) 100%);
662 + border: none;
663 + border-radius: var(--bs-border-radius-lg) var(--bs-border-radius-lg) 0 0;
664 +}
665 +
666 +.icon-customization-modal .modal-title {
667 + font-weight: 600;
668 + color: white;
669 +}
670 +
671 +.icon-customization-modal .modal-body {
672 + padding: 1.5rem;
673 + background: var(--bs-gray-50);
674 +}
675 +
676 +/* Ensure close button is visible in all themes */
677 +.modal-header .btn-close {
678 + filter: brightness(0) invert(1);
679 + opacity: 1;
680 + transition: opacity 0.2s;
681 +}
682 +
683 +.modal-header .btn-close:hover {
684 + opacity: 0.8;
685 +}
686 +
687 +/* App night mode: force white close icon for icon customization modal */
688 +body.night .icon-customization-modal .btn-close,
689 +[data-bs-theme="dark"] .icon-customization-modal .btn-close {
690 + filter: none;
691 + opacity: 1;
692 + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-linecap='round' stroke-width='2'%3e%3cpath d='M2 2l12 12M14 2L2 14'/%3e%3c/svg%3e");
693 +}
694 +
695 +body.night .icon-customization-modal .btn-close:hover,
696 +[data-bs-theme="dark"] .icon-customization-modal .btn-close:hover {
697 + opacity: 0.85;
698 +}
699 +
700 +/* Responsive Design */
701 +@media (max-width: 768px) {
702 + .icon-preview-container {
703 + flex-direction: column;
704 + align-items: flex-start;
705 + gap: 0.5rem;
706 + }
707 +
708 + .icon-preview-item {
709 + margin-right: 0;
710 + }
711 +}
712 +
713 +/* Dark Mode Support */
714 +@media (prefers-color-scheme: dark) {
715 + .icon-customization-modal .modal-body {
716 + background: var(--bs-gray-900);
717 + }
718 +
719 + .modern-card {
720 + background-color: var(--bs-gray-800);
721 + border-color: var(--bs-gray-700);
722 + }
723 +
724 + .icon-preview-container {
725 + background-color: var(--bs-gray-800);
726 + }
727 +
728 + .icon-preview-item {
729 + background-color: var(--bs-gray-700);
730 + border-color: var(--bs-gray-600);
731 + }
732 +}
733 +
734 #id_dialogclose {
735 float: right;
736 padding: 3px;
@@ -759,7 +935,7 @@ body {
935 margin-left: 5px;
936 }
937
762 -/* Example if <table> is relplaced with <div><p> then image can be defined in css
938 +/* Example if <table> is relplaced with <div><p> then image can be defined in css
939 #NoMeshesPanel {
940 background: url(../images/info.png) no-repeat 23px 20px;
941 height: 48px;
@@ -2655,7 +2831,7 @@ a {
2831 .deskToolsBar:hover {
2832 background-color: #EFE8B6;
2833 }
2658 -
2834 +
2835 .night .deskToolsBar {
2836 color: #ddd;
2837 }
views/default3.handlebars
+357 -20
@@ -60,6 +60,7 @@
60 <script keeplink=1 type="text/javascript" src="scripts/ol3-contextmenu{{{min}}}.js"></script>
61 <script keeplink=1 type="text/javascript" src="scripts/purify{{{min}}}.js"></script>
62 <script keeplink=1 type="text/javascript" src="scripts/marked{{{min}}}.js"></script>
63 + <script type="text/javascript" src="js/ui-components.js"></script>
64 {{{customJSTags}}}
65 <title>{{{title}}}</title>
66 </head>
@@ -641,6 +642,7 @@
642 </span>
643 <span id="accountCreateLoginTokenSpan" style="display:none"><a href=# onclick="return account_createLoginToken()">Create login token</a><br /></span>
644 <a href=# onclick="return account_showThemesSwitcher()">Switch theme</a><br />
645 + <span id="accountCustomIconsSpan" style="display:none"><a href=# onclick="return showIconCustomization()">Icons Customization</a><br /></span>
646 </p>
647 </div>
648 <div id=p2logintokens></div>
@@ -2689,7 +2691,7 @@
2691 if (userImage) {
2692 userImage.src = userImageSrc;
2693 }
2692 -
2694 +
2695 var userDropdownButton = Q('userDropdownButton');
2696 if (userDropdownButton) {
2697 userDropdownButton.onclick = function(e) {
@@ -2699,7 +2701,7 @@
2701 return false;
2702 };
2703 }
2702 -
2704 +
2705 var userDropdownMenu = Q('userDropdownMenu');
2706 if (userDropdownMenu) {
2707 userDropdownMenu.style.display = 'none';
@@ -2715,7 +2717,7 @@
2717 var uiSubmenu = Q('uiSubmenu');
2718 if (uiSubmenu) {
2719 var isVisible = (uiSubmenu.style.display == 'block');
2718 -
2720 +
2721 if (isVisible) {
2722 uiSubmenu.style.display = 'none';
2723 uiSubmenu.classList.remove('show');
@@ -2737,7 +2739,7 @@
2739 }
2740 }
2741 }
2740 -
2742 +
2743 function closeUISubmenu(event) {
2744 var uiSubmenu = Q('uiSubmenu');
2745 var userDropdown = Q('userDropdown');
@@ -2975,6 +2977,7 @@
2977 QV('p2ServerActions', (siteRights & 21) && ((serverFeatures & 143) != 0));
2978 QV('LeftMenuMyServer', (siteRights & 21) && ((serverFeatures & 64) != 0)); // 16 + 4 + 1
2979 QV('MainMenuMyServer', siteRights & 21);
2980 + QV('accountCustomIconsSpan', true);
2981 QV('p2ServerActionsBackup', (siteRights & 1) && ((serverFeatures & 1) != 0));
2982 QV('p2ServerActionsRestore', (siteRights & 4) && ((serverFeatures & 2) != 0));
2983 QV('p2ServerActionsVersion', (siteRights & 16) && ((serverFeatures & 4) != 0));
@@ -3360,7 +3363,7 @@
3363
3364 // If groups are to be collapsed by default, do it now
3365 if (collapseGroups === 'true' && Object.keys(CollapsedGroups).length === 0 && typeof(getstore('_collapse')) === 'undefined') { cmexpandaction(2); }
3363 -
3366 +
3367 break;
3368 }
3369 case 'powertimeline': {
@@ -4003,6 +4006,8 @@
4006
4007 var webstate = JSON.parse(message.event.state);
4008 for (var i in webstate) { localStorage.setItem(i, webstate[i]); }
4009 + customIconValues = loadCustomIconState();
4010 + applyIconCustomization(customIconValues);
4011
4012 // Update the web page
4013 //if ((webstate.deskAspectRatio != null) && (webstate.deskAspectRatio != deskAspectRatio)) { deskAspectRatio = webstate.deskAspectRatio; deskAdjust(); }
@@ -13581,7 +13586,7 @@
13586 if (battery.RemainingCapacity) { x += addDetailItem("Remaining Capacity", format("{0} mWh", battery.RemainingCapacity), s); }
13587 if (battery.Voltage) { x += addDetailItem("Voltage", format("{0} V", (battery.Voltage / 1000)), s); }
13588 if (battery.Health) { x += addDetailItem("Health", format("{0} %", battery.Health), s); }
13584 - if (battery.BatteryCharge) { x += addDetailItem("Battery Charge", format("{0} %", battery.BatteryCharge), s); }
13589 + if (battery.BatteryCharge) { x += addDetailItem("Battery Charge", format("{0} %", battery.BatteryCharge), s); }
13590 x += '</div>';
13591 }
13592 x += '</table>';
@@ -19004,9 +19009,9 @@
19009 meshserver.send({ action: 'removemeshuser', meshid: meshid, userid: currentUser._id });
19010 }
19011
19007 -
19012 +
19013 //
19009 - // UserDropDown Menu
19014 + // UserDropDown Menu
19015 //
19016
19017 var userDropdownOpen = false;
@@ -19017,9 +19022,9 @@
19022 var dropdownMenu = Q('userDropdownMenu');
19023 var uiSettingsButton = document.querySelector('.userDropdownUISettings');
19024 var uiSubmenu = Q('uiSubmenu');
19020 -
19025 +
19026 if (!dropdownButton || !dropdownMenu) return;
19022 -
19027 +
19028 QS('uiSubmenu').display = 'none';
19029 QS('uiSubmenu').opacity = '0';
19030 QS('uiSubmenu').transform = 'translateX(12px)';
@@ -19055,14 +19060,14 @@
19060
19061 function toggleUISubmenu() {
19062 uiSubmenuOpen = !uiSubmenuOpen;
19058 -
19063 +
19064 var uiSubmenu = Q('uiSubmenu');
19065 var isDesktop = window.innerWidth > 769;
19066 var reduceMotion = (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
19062 -
19067 +
19068 if (uiSubmenuOpen) {
19069 QS('uiSubmenu').display = 'block';
19065 -
19070 +
19071 if (isDesktop && (reduceMotion == false)) {
19072 setTimeout(function() {
19073 QS('uiSubmenu').opacity = '1';
@@ -19085,7 +19090,7 @@
19090 QS('uiSubmenu').transform = 'translateX(12px)';
19091 }
19092 }
19088 -
19093 +
19094 var chevronIcon = document.querySelector('.userDropdownUISettings .fa-chevron-right');
19095 if (chevronIcon) {
19096 chevronIcon.style.transform = uiSubmenuOpen ? 'rotate(90deg)' : '';
@@ -19115,7 +19120,7 @@
19120 case 'toggle-night': toggleNightMode(); break;
19121 case 'notes': showNotes(false); break;
19122 case 'account': go(2); break;
19118 - case 'logout':
19123 + case 'logout':
19124 if (logoutControls && logoutControls.logoutUrl) {
19125 window.location.href = logoutControls.logoutUrl;
19126 }
@@ -19132,11 +19137,11 @@
19137 var userDropdown = Q('userDropdown');
19138 var dropdownName = Q('userDropdownName');
19139 var dropdownImage = Q('userDropdownImage');
19135 -
19140 +
19141 if (logoutControls && logoutControls.name) {
19142 QS('userDropdown').display = 'block';
19143 dropdownName.textContent = logoutControls.name;
19139 -
19144 +
19145 if (userinfo && userinfo.flags && (userinfo.flags & 1)) {
19146 var rnd = userinfo.accountImageRnd || Math.floor(Math.random() * 9999999999);
19147 dropdownImage.src = 'userimage.ashx?rnd=' + rnd;
@@ -19361,7 +19366,7 @@
19366 if (rec.meshname) { x += addHtmlValue4("Device Group", EscapeHtml(rec.meshname)); }
19367 if (rec.size) { x += addHtmlValue4("Size", format("{0} bytes", rec.size)); }
19368 if (rec.startTime) { x += addHtmlValue4("Start Time", printTime(new Date(rec.startTime))); }
19364 - if (rec.startTime && rec.lengthTime) { x += addHtmlValue4("End Time", printTime(new Date(rec.startTime + (rec.lengthTime * 1000)))); }
19369 + if (rec.startTime && rec.lengthTime) { x += addHtmlValue4("End Time", printTime(new Date(rec.startTime + (rec.lengthTime * 1000)))); }
19370 if (rec.lengthTime) { x += addHtmlValue4("Duration", pad2(Math.floor(rec.lengthTime / 3600)) + ':' + pad2(Math.floor((rec.lengthTime % 3600) / 60)) + ':' + pad2(Math.floor(rec.lengthTime % 60))); }
19371 if (rec.multiplex == true) { x += addHtmlValue4("Multiplexor", "Enabled"); }
19372 if (rec.userids) { for (var i in rec.userids) { x += addHtmlValue4("User", rec.userids[i].split('/')[2]); } }
@@ -19884,7 +19889,7 @@
19889 }
19890 setupNotificationClickOutside();
19891
19887 -
19892 +
19893 // Refresh the notification box
19894 function drawNotifications() {
19895 var notifySettings = getstore('notifications', 0);
@@ -21313,11 +21318,343 @@
21318 }
21319 }
21320
21321 + // --- Icons Customization ---
21322 + var customIconValues = {};
21323 + const customIconConfig = [
21324 + { key: 'myDevices', label: 'My Devices', elementId: 'LeftMenuMyDevices' },
21325 + { key: 'myAccount', label: 'My Account', elementId: 'LeftMenuMyAccount' },
21326 + { key: 'myEvents', label: 'My Events', elementId: 'LeftMenuMyEvents' },
21327 + { key: 'myFiles', label: 'My Files', elementId: 'LeftMenuMyFiles' },
21328 + { key: 'myUsers', label: 'My Users', elementId: 'LeftMenuMyUsers' },
21329 + { key: 'myServer', label: 'My Server', elementId: 'LeftMenuMyServer' }
21330 + ];
21331 +
21332 + function getVisibleCustomIconConfig() {
21333 + var visibleConfig = [];
21334 + for (var i = 0; i < customIconConfig.length; i++) {
21335 + var cfg = customIconConfig[i];
21336 + var anchor = document.getElementById(cfg.elementId);
21337 + if (anchor == null) { continue; }
21338 + if (window.getComputedStyle(anchor).display === 'none') { continue; }
21339 + visibleConfig.push(cfg);
21340 + }
21341 + return visibleConfig;
21342 + }
21343 +
21344 + function loadCustomIconState() {
21345 + var raw = getstore('customIcons', '{}');
21346 + if ((typeof raw !== 'string') || (raw.length === 0)) { return {}; }
21347 + try { return JSON.parse(raw); } catch (ex) { return {}; }
21348 + }
21349 +
21350 + function normalizeCustomIconPath(value) {
21351 + if (typeof value !== 'string') { return value; }
21352 + var trimmed = value.trim();
21353 + if (trimmed.length === 0) { return trimmed; }
21354 + var lower = trimmed.toLowerCase();
21355 + if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('data:')) { return trimmed; }
21356 + if ((typeof domainUrl === 'string') && (domainUrl !== '/') && (domainUrl.length > 0)) {
21357 + if (trimmed.startsWith('/icons/custom/')) {
21358 + return domainUrl + trimmed.substring(1);
21359 + }
21360 + if (trimmed.startsWith('icons/custom/')) {
21361 + return domainUrl + trimmed;
21362 + }
21363 + }
21364 + if (trimmed.startsWith('icons/custom/')) { return '/' + trimmed; }
21365 + return trimmed;
21366 + }
21367 +
21368 + function sanitizeCustomIconState(state) {
21369 + var sanitized = {};
21370 + if (state == null) { return sanitized; }
21371 + for (var i = 0; i < customIconConfig.length; i++) {
21372 + var key = customIconConfig[i].key;
21373 + var value = state[key];
21374 + if (typeof value === 'string') {
21375 + var trimmed = normalizeCustomIconPath(value.trim());
21376 + if (trimmed.length > 0) { sanitized[key] = trimmed; }
21377 + }
21378 + }
21379 + return sanitized;
21380 + }
21381 +
21382 + function persistCustomIconState(state) {
21383 + var sanitized = sanitizeCustomIconState(state);
21384 + putstore('customIcons', JSON.stringify(sanitized));
21385 + customIconValues = sanitized;
21386 + applyIconCustomization(sanitized);
21387 + }
21388 +
21389 + function showIconCustomization() {
21390 + customIconValues = loadCustomIconState();
21391 + var visibleIconConfig = getVisibleCustomIconConfig();
21392 + var x = '<div class="container-fluid">';
21393 + x += '<div class="row mb-4">';
21394 + x += '<div class="col-12">';
21395 + x += '<div class="d-flex align-items-center mb-3">';
21396 + x += '<div class="bg-primary bg-gradient rounded-circle p-3 me-3">';
21397 + x += '<i class="fas fa-palette fa-2x text-white"></i>';
21398 + x += '</div>';
21399 + x += '<div>';
21400 + x += '<h5 class="mb-1 fw-semibold">Customize Your Sidebar Icons</h5>';
21401 + x += '<p class="text-muted mb-0">Upload custom SVG/PNG icons or provide URLs to personalize your sidebar interface experience</p>';
21402 + x += '</div>';
21403 + x += '</div>';
21404 + x += '</div>';
21405 + x += '</div>';
21406 +
21407 + x += '<div class="row" id="iconCardsContainer">';
21408 + for (var i = 0; i < visibleIconConfig.length; i++) {
21409 + var cfg = visibleIconConfig[i];
21410 + var currentValue = customIconValues[cfg.key] || '';
21411 + var hasIcon = currentValue.length > 0;
21412 +
21413 + x += '<div class="col-lg-6 col-xl-4 mb-3">';
21414 + x += '<div class="card modern-card h-100 ' + (hasIcon ? 'border-success' : '') + '" data-icon-key="' + cfg.key + '">';
21415 + x += '<div class="card-header d-flex align-items-center">';
21416 + x += '<div class="bg-light rounded-circle p-2 me-3">';
21417 + x += '<i class="fas ' + getIconClass(cfg.key) + ' fa-lg text-secondary"></i>';
21418 + x += '</div>';
21419 + x += '<div class="flex-grow-1">';
21420 + x += '<h6 class="card-title mb-1">' + EscapeHtml(cfg.label) + '</h6>';
21421 + x += '<small class="status-badge ' + (hasIcon ? 'text-success' : 'text-muted') + '">';
21422 + x += '<i class="fas ' + (hasIcon ? 'fa-check-circle' : 'fa-circle') + ' me-1"></i>';
21423 + x += '<span class="status-text">' + (hasIcon ? 'Custom icon set' : 'Default icon') + '</span>';
21424 + x += '</small>';
21425 + x += '</div>';
21426 + x += '</div>';
21427 +
21428 + x += '<div class="card-body">';
21429 + x += '<div class="icon-upload-wrapper" data-icon-key="' + cfg.key + '"></div>';
21430 + x += '</div>';
21431 + x += '</div>';
21432 + x += '</div>';
21433 + }
21434 + if (visibleIconConfig.length === 0) {
21435 + x += '<div class="col-12"><div class="alert alert-secondary mb-0">No sidebar icons are available for customization on this account.</div></div>';
21436 + }
21437 + x += '</div>';
21438 + x += '</div>';
21439 +
21440 + openModal({
21441 + modalId: 'xxAddAgent',
21442 + title: 'Icons Customization',
21443 + body: x,
21444 + size: 'large',
21445 + okButtonId: 'idx_dlgOkButton',
21446 + onOk: saveIconCustomization
21447 + });
21448 +
21449 + // Initialize icon upload components after modal is shown
21450 + setTimeout(function() {
21451 + initializeIconUploadComponents();
21452 + }, 100);
21453 +
21454 + return false;
21455 + }
21456 +
21457 + function initializeIconUploadComponents() {
21458 + var visibleIconConfig = getVisibleCustomIconConfig();
21459 + for (var i = 0; i < visibleIconConfig.length; i++) {
21460 + var cfg = visibleIconConfig[i];
21461 + var currentValue = customIconValues[cfg.key] || '';
21462 + var wrapper = document.querySelector('[data-icon-key="' + cfg.key + '"] .icon-upload-wrapper');
21463 +
21464 + if (wrapper) {
21465 + createIconUploadComponent(cfg.key, wrapper, {
21466 + label: cfg.label,
21467 + currentValue: currentValue,
21468 + onUpload: uploadAndPersistCustomIcon,
21469 + onUrlInput: handleUrlInput,
21470 + onRemove: removeCustomIcon,
21471 + normalizePreviewUrl: normalizeCustomIconPath
21472 + });
21473 + }
21474 + }
21475 + }
21476 +
21477 + // Upload via API, then persist local icon state so sidebar updates immediately.
21478 + async function uploadAndPersistCustomIcon(iconKey, file) {
21479 + var result = await uploadCustomIcon(iconKey, file);
21480 + if ((result != null) && (typeof result.path === 'string') && (result.path.length > 0)) {
21481 + customIconValues[iconKey] = result.path;
21482 + persistCustomIconState(customIconValues);
21483 + setIconCardStatus(iconKey, 'custom');
21484 + }
21485 + return result;
21486 + }
21487 +
21488 + function setIconCardStatus(iconKey, state) {
21489 + var card = document.querySelector('[data-icon-key="' + iconKey + '"]');
21490 + if (!card) { return; }
21491 + var statusElement = card.querySelector('.card-header small');
21492 + card.classList.remove('border-warning', 'border-success');
21493 + if (state === 'custom') {
21494 + if (statusElement) {
21495 + statusElement.className = 'text-success';
21496 + statusElement.innerHTML = '<i class="fas fa-check-circle me-1"></i>Custom icon set';
21497 + }
21498 + card.classList.add('border-success');
21499 + } else if (state === 'unsaved') {
21500 + if (statusElement) {
21501 + statusElement.className = 'text-warning';
21502 + statusElement.innerHTML = '<i class="fas fa-exclamation-circle me-1"></i>Unsaved changes';
21503 + }
21504 + card.classList.add('border-warning');
21505 + } else {
21506 + if (statusElement) {
21507 + statusElement.className = 'text-muted';
21508 + statusElement.innerHTML = '<i class="fas fa-circle me-1"></i>Default icon';
21509 + }
21510 + }
21511 + }
21512 +
21513 + function getIconClass(iconKey) {
21514 + const iconMap = {
21515 + 'myDevices': 'fa-desktop',
21516 + 'myAccount': 'fa-user-circle',
21517 + 'myEvents': 'fa-calendar-alt',
21518 + 'myFiles': 'fa-folder',
21519 + 'myUsers': 'fa-users',
21520 + 'myServer': 'fa-server'
21521 + };
21522 + return iconMap[iconKey] || 'fa-icons';
21523 + }
21524 +
21525 + function handleUrlInput(iconKey, inputOrValue) {
21526 + var value = '';
21527 + if (typeof inputOrValue === 'string') { value = inputOrValue.trim(); }
21528 + else if (inputOrValue && (typeof inputOrValue.value === 'string')) { value = inputOrValue.value.trim(); }
21529 + // Preview rendering is handled by IconUploadComponent.
21530 + setIconCardStatus(iconKey, (value.length > 0) ? 'unsaved' : 'default');
21531 + }
21532 +
21533 + function removeCustomIcon(iconKey) {
21534 + var previousValue = (customIconValues && (typeof customIconValues[iconKey] === 'string')) ? customIconValues[iconKey] : '';
21535 + customIconValues[iconKey] = '';
21536 + var textInput = document.getElementById('iconInput_' + iconKey);
21537 + if (textInput) { textInput.value = ''; }
21538 + persistCustomIconState(customIconValues);
21539 +
21540 + if ((typeof previousValue === 'string') && (previousValue.indexOf('/icons/custom/') >= 0)) {
21541 + deleteCustomIconFromServer(previousValue).catch(function (ex) {
21542 + // Keep UX non-blocking: icon is already reset locally.
21543 + if (window.console) { console.error('Failed to delete custom icon:', ex); }
21544 + });
21545 + }
21546 +
21547 + setIconCardStatus(iconKey, 'default');
21548 + return false;
21549 + }
21550 +
21551 + async function uploadCustomIcon(iconKey, file) {
21552 + var formData = new FormData();
21553 + formData.append('iconType', iconKey);
21554 + if (customIconValues && typeof customIconValues[iconKey] === 'string' && customIconValues[iconKey].length > 0) {
21555 + formData.append('previousIcon', customIconValues[iconKey]);
21556 + }
21557 + formData.append('iconFile', file);
21558 +
21559 + var response = await fetch('customiconupload.ashx', { method: 'POST', body: formData, credentials: 'same-origin' });
21560 + if (!response.ok) {
21561 + var message = 'Failed to upload the icon.';
21562 + try {
21563 + var errorInfo = await response.json();
21564 + if (errorInfo && typeof errorInfo.error === 'string' && errorInfo.error.length > 0) { message = errorInfo.error; }
21565 + } catch (ex) { }
21566 + throw new Error(message);
21567 + }
21568 + return response.json();
21569 + }
21570 +
21571 + async function deleteCustomIconFromServer(iconPath) {
21572 + var body = new URLSearchParams();
21573 + body.append('iconPath', iconPath);
21574 + var response = await fetch('customicondelete.ashx', {
21575 + method: 'POST',
21576 + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
21577 + body: body.toString(),
21578 + credentials: 'same-origin'
21579 + });
21580 + if (!response.ok) {
21581 + var message = 'Failed to delete custom icon.';
21582 + try {
21583 + var errorInfo = await response.json();
21584 + if (errorInfo && typeof errorInfo.error === 'string' && errorInfo.error.length > 0) { message = errorInfo.error; }
21585 + } catch (ex) { }
21586 + throw new Error(message);
21587 + }
21588 + return response.json();
21589 + }
21590 +
21591 + function saveIconCustomization() {
21592 + var updatedState = loadCustomIconState();
21593 + var visibleIconConfig = getVisibleCustomIconConfig();
21594 + for (var i = 0; i < visibleIconConfig.length; i++) {
21595 + var cfg = visibleIconConfig[i];
21596 + var input = document.getElementById('iconInput_' + cfg.key);
21597 + if (input) { updatedState[cfg.key] = input.value; }
21598 + }
21599 + persistCustomIconState(updatedState);
21600 + if (xxModal) { xxModal.hide(); }
21601 + }
21602 +
21603 + // Supports both SVG menu icons and the modern FontAwesome <i> icons.
21604 + function applyIconCustomization(icons) {
21605 + var state = sanitizeCustomIconState(icons);
21606 + for (var i = 0; i < customIconConfig.length; i++) {
21607 + var cfg = customIconConfig[i];
21608 + var anchor = document.getElementById(cfg.elementId);
21609 + if (!anchor) { continue; }
21610 + var iconElement = anchor.querySelector('i,svg');
21611 + if (!iconElement) { continue; }
21612 + if (state[cfg.key]) {
21613 + anchor.classList.add('custom-icon');
21614 + iconElement.style.setProperty('background-image', 'url("' + state[cfg.key].replace(/"/g, '%22') + '")', 'important');
21615 + iconElement.style.setProperty('background-repeat', 'no-repeat', 'important');
21616 + iconElement.style.setProperty('background-position', 'center', 'important');
21617 + iconElement.style.setProperty('background-size', 'contain', 'important');
21618 + if (iconElement.tagName.toLowerCase() === 'i') {
21619 + // Font Awesome icons are pseudo-elements; hide glyph and show background image.
21620 + iconElement.style.setProperty('font-size', '0', 'important');
21621 + iconElement.style.setProperty('color', 'transparent', 'important');
21622 + iconElement.style.setProperty('width', '1.1em', 'important');
21623 + iconElement.style.setProperty('height', '1.1em', 'important');
21624 + iconElement.style.setProperty('display', 'inline-block', 'important');
21625 + iconElement.style.setProperty('vertical-align', 'middle', 'important');
21626 + }
21627 + } else {
21628 + anchor.classList.remove('custom-icon');
21629 + iconElement.style.removeProperty('background-image');
21630 + iconElement.style.removeProperty('background-repeat');
21631 + iconElement.style.removeProperty('background-position');
21632 + iconElement.style.removeProperty('background-size');
21633 + if (iconElement.tagName.toLowerCase() === 'i') {
21634 + iconElement.style.removeProperty('font-size');
21635 + iconElement.style.removeProperty('color');
21636 + iconElement.style.removeProperty('width');
21637 + iconElement.style.removeProperty('height');
21638 + iconElement.style.removeProperty('display');
21639 + iconElement.style.removeProperty('vertical-align');
21640 + }
21641 + }
21642 + }
21643 + }
21644 +
21645 + document.addEventListener('DOMContentLoaded', function () {
21646 + customIconValues = loadCustomIconState();
21647 + applyIconCustomization(customIconValues);
21648 + });
21649 +
21650 + window.addEventListener('load', function () {
21651 + applyIconCustomization(customIconValues);
21652 + });
21653 +
21654 // Request Confirmation if closing while a desktop, terminal session is active
21655 window.addEventListener('beforeunload', function (e) {
21656 if (((desktop != null) && (xxcurrentView == 11)) || ((terminal != null) && (xxcurrentView == 12))) { e.preventDefault(); e.returnValue = ''; }
21657 });
21320 -
21658 </script>
21659 </body>
21660
webserver.js
+262 -74
@@ -39,6 +39,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
39 obj.net = require('net');
40 obj.tls = require('tls');
41 obj.path = require('path');
42 + obj.os = require('os');
43 obj.bodyParser = require('body-parser');
44 obj.exphbs = require('express-handlebars');
45 obj.crypto = require('crypto');
@@ -98,6 +99,37 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
99 obj.renderLanguages = [];
100 obj.destroyedSessions = {}; // userid/req.session.x --> destroyed session time
101
102 + const isWindowsPlatform = (obj.os.platform() === 'win32');
103 + const safeUploadTempRoots = (function () {
104 + const roots = [];
105 + const addRoot = function (p) {
106 + if (typeof p !== 'string') { return; }
107 + var resolved;
108 + try { resolved = obj.path.normalize(obj.path.resolve(p)); } catch (ex) { return; }
109 + if (resolved.length === 0) { return; }
110 + if ((resolved.length > 1) && resolved.endsWith(obj.path.sep)) { resolved = resolved.slice(0, -1); }
111 + const comparison = isWindowsPlatform ? resolved.toLowerCase() : resolved;
112 + const comparisonWithSep = comparison + obj.path.sep;
113 + roots.push({ comparison: comparison, comparisonWithSep: comparisonWithSep });
114 + };
115 + addRoot(obj.os.tmpdir());
116 + if (typeof obj.parent.filespath === 'string') { addRoot(obj.path.join(obj.parent.filespath, 'tmp')); }
117 + return roots;
118 + })();
119 + function resolveSafeUploadTempPath(tempPath) {
120 + if (typeof tempPath !== 'string') { return null; }
121 + var resolvedPath;
122 + try { resolvedPath = obj.path.normalize(obj.path.resolve(tempPath)); } catch (ex) { return null; }
123 + var comparisonPath = isWindowsPlatform ? resolvedPath.toLowerCase() : resolvedPath;
124 + var comparisonPathNoTrailing = comparisonPath;
125 + if ((comparisonPathNoTrailing.length > 1) && comparisonPathNoTrailing.endsWith(obj.path.sep)) { comparisonPathNoTrailing = comparisonPathNoTrailing.slice(0, -1); }
126 + for (var i = 0; i < safeUploadTempRoots.length; i++) {
127 + var root = safeUploadTempRoots[i];
128 + if ((comparisonPathNoTrailing === root.comparison) || comparisonPath.startsWith(root.comparisonWithSep)) { return resolvedPath; }
129 + }
130 + return null;
131 + }
132 +
133 // Web relay sessions
134 var webRelayNextSessionId = 1;
135 var webRelaySessions = {} // UserId/SessionId/Host --> Web Relay Session
@@ -2125,7 +2157,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
2157 }
2158 }
2159 }
2128 - });
2160 + });
2161 }
2162 } else {
2163 render(req, res, getRenderPage((domain.sitestyle >= 2) ? 'message2' : 'message', req, domain), getRenderArgs({ titleid: 1, msgid: 10, domainurl: encodeURIComponent(domain.url).replace(/'/g, '%27') }, req, domain));
@@ -2881,12 +2913,12 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
2913 res.set('Content-Type', 'text/html');
2914 let url = domain.url;
2915 if (Object.keys(req.query).length > 0) { url += "?" + Object.keys(req.query).map(function(key) { return encodeURIComponent(key) + "=" + encodeURIComponent(req.query[key]); }).join("&"); }
2884 -
2916 +
2917 // check for relaystate is set, test against configured server name and accepted query params
2918 if(req.body && req.body.RelayState !== undefined){
2919 var relayState = decodeURIComponent(req.body.RelayState);
2920 var serverName = (obj.getWebServerName(domain, req)).replaceAll('.','\\.');
2889 -
2921 +
2922 var regexstr = `(?<=https:\\/\\/(?:.+?\\.)?${serverName}\\/?)` +
2923 `.*((?<=([\\?&])gotodevicename=(.{64})|` +
2924 `gotonode=(.{64})|` +
@@ -2906,13 +2938,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
2938 `webrtc=|` +
2939 `hide=|` +
2940 `viewmode=(\\d+)(?=[\\&]|\\b)))`;
2909 -
2941 +
2942 var regex = new RegExp(regexstr);
2943 if(regex.test(relayState)){
2944 url = relayState;
2945 }
2946 }
2915 -
2947 +
2948 res.end('<html><head><meta http-equiv="refresh" content=0;url="' + url + '"></head><body></body></html>');
2949 }
2950
@@ -3234,7 +3266,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
3266 // Get WebRTC configuration
3267 var webRtcConfig = null;
3268 if (obj.parent.config.settings && obj.parent.config.settings.webrtcconfig && (typeof obj.parent.config.settings.webrtcconfig == 'object')) { webRtcConfig = encodeURIComponent(JSON.stringify(obj.parent.config.settings.webrtcconfig)).replace(/'/g, '%27'); }
3237 - else if (args.webrtcconfig && (typeof args.webrtcconfig == 'object')) { webRtcConfig = encodeURIComponent(JSON.stringify(args.webrtcconfig)).replace(/'/g, '%27'); }
3269 + else if (args.webrtcconfig && (typeof args.webrtcconfig == 'object')) { webRtcConfig = encodeURIComponent(JSON.stringify(args.webrtcconfig)).replace(/'/g, '%27'); }
3270
3271 // Load default page style or new modern ui
3272 var uiViewMode = 'default';
@@ -3268,7 +3300,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
3300 customui: customui,
3301 customFiles: customFiles,
3302 webcerthash: Buffer.from(obj.webCertificateFullHashs[domain.id], 'binary').toString('base64').replace(/\+/g, '@').replace(/\//g, '$'),
3271 - footer: (domain.footer == null) ? '' : obj.common.replacePlaceholders(domain.footer, {
3303 + footer: (domain.footer == null) ? '' : obj.common.replacePlaceholders(domain.footer, {
3304 'serverversion': obj.parent.currentVer,
3305 'servername': obj.getWebServerName(domain, req),
3306 'agentsessions': Object.keys(parent.webserver.wsagents).length,
@@ -3578,8 +3610,8 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
3610 messageid: msgid,
3611 flashErrors: JSON.stringify(flashErrors).replace(/"/g, '\\"'),
3612 passhint: passhint,
3581 -
3582 - welcometext: domain.welcometext ? encodeURIComponent(obj.common.replacePlaceholders(domain.welcometext, {
3613 +
3614 + welcometext: domain.welcometext ? encodeURIComponent(obj.common.replacePlaceholders(domain.welcometext, {
3615 'serverversion': obj.parent.currentVer,
3616 'servername': obj.getWebServerName(domain, req),
3617 'agentsessions': Object.keys(parent.webserver.wsagents).length,
@@ -4551,14 +4583,17 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4583 const nodeid = fields.attrib[0];
4584 obj.GetNodeWithRights(domain, user, nodeid, function (node, rights, visible) {
4585 if ((node == null) || (rights != 0xFFFFFFFF) || (visible == false)) { res.sendStatus(404); return; } // We don't have remote control rights to this device
4554 - files.files.forEach(function (file) {
4555 - obj.fs.readFile(file.path, 'utf8', function (err, data) {
4586 + for (var i in files.files) {
4587 + var file = files.files[i];
4588 + const uploadTempPath = resolveSafeUploadTempPath(file.path);
4589 + if (uploadTempPath == null) { res.sendStatus(400); return; }
4590 + obj.fs.readFile(uploadTempPath, 'utf8', function (err, data) {
4591 if (err != null) return;
4592 data = obj.common.IntToStr(0) + data; // Add the 4 bytes encoding type & flags (Set to 0 for raw)
4593 obj.sendMeshAgentCore(user, domain, fields.attrib[0], 'custom', data); // Upload the core
4559 - try { obj.fs.unlinkSync(file.path); } catch (e) { }
4594 + try { obj.fs.unlinkSync(uploadTempPath); } catch (e) { }
4595 });
4561 - });
4596 + }
4597 res.send('');
4598 });
4599 });
@@ -4593,18 +4628,165 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4628 const nodeid = fields.attrib[0];
4629 obj.GetNodeWithRights(domain, user, nodeid, function (node, rights, visible) {
4630 if ((node == null) || (rights != 0xFFFFFFFF) || (visible == false)) { res.sendStatus(404); return; } // We don't have remote control rights to this device
4596 - files.files.forEach(function (file) {
4631 + for (var i in files.files) {
4632 + var file = files.files[i];
4633 + const uploadTempPath = resolveSafeUploadTempPath(file.path);
4634 + if (uploadTempPath == null) { res.sendStatus(400); return; }
4635 +
4636 // Event Intel AMT One Click Recovery, this will cause Intel AMT wake operations on this and other servers.
4598 - parent.DispatchEvent('*', obj, { action: 'oneclickrecovery', userid: user._id, username: user.name, nodeids: [node._id], domain: domain.id, nolog: 1, file: file.path });
4637 + parent.DispatchEvent('*', obj, { action: 'oneclickrecovery', userid: user._id, username: user.name, nodeids: [node._id], domain: domain.id, nolog: 1, file: uploadTempPath });
4638
4600 - //try { obj.fs.unlinkSync(file.path); } catch (e) { } // TODO: Remove this file after 30 minutes.
4601 - });
4639 + //try { obj.fs.unlinkSync(uploadTempPath); } catch (e) { } // TODO: Remove this file after 30 minutes.
4640 + }
4641 res.send('');
4642 });
4643 });
4644 }
4645
4646 // Upload a file to the server
4647 + function getCustomIconUserKey(user) {
4648 + if ((user == null) || (typeof user._id !== 'string') || (user._id.length === 0)) { return null; }
4649 + return obj.crypto.createHash('sha256').update(user._id).digest('hex');
4650 + }
4651 +
4652 + function getCustomIconUserDir(user) {
4653 + const userKey = getCustomIconUserKey(user);
4654 + if (userKey == null) { return null; }
4655 + return obj.path.join(obj.parent.datapath, 'icons', 'custom', userKey);
4656 + }
4657 +
4658 + function handleCustomIconUpload(req, res) {
4659 + const domain = checkUserIpAddress(req, res);
4660 + if (domain == null) { return; }
4661 + if ((req.session == null) || (typeof req.session.userid !== 'string')) { res.sendStatus(401); return; }
4662 + const user = obj.users[req.session.userid];
4663 + if (user == null) { res.sendStatus(401); return; }
4664 +
4665 + const multiparty = require('multiparty');
4666 + const form = new multiparty.Form();
4667 + form.parse(req, function (err, fields, files) {
4668 + if (err) { res.status(400).json({ success: false, error: 'Invalid form submission.' }); return; }
4669 +
4670 + const allowedTypes = { myDevices: 1, myAccount: 1, myEvents: 1, myFiles: 1, myUsers: 1, myServer: 1 };
4671 + const iconType = (fields && fields.iconType && fields.iconType[0]) ? fields.iconType[0] : null;
4672 + if ((typeof iconType !== 'string') || (allowedTypes[iconType] !== 1)) { res.status(400).json({ success: false, error: 'Invalid icon type.' }); return; }
4673 +
4674 + const iconFile = (files && files.iconFile && files.iconFile[0]) ? files.iconFile[0] : null;
4675 + if ((iconFile == null) || (typeof iconFile.path !== 'string')) { res.status(400).json({ success: false, error: 'Missing icon file.' }); return; }
4676 + const iconTempPath = resolveSafeUploadTempPath(iconFile.path);
4677 + if (iconTempPath == null) { res.status(400).json({ success: false, error: 'Invalid icon file location.' }); return; }
4678 +
4679 + const cleanupTempFile = function () { try { obj.fs.unlink(iconTempPath, function () { }); } catch (ex) { } };
4680 +
4681 + const extension = obj.path.extname(iconFile.originalFilename || '').toLowerCase();
4682 + if ((extension !== '.svg') && (extension !== '.png')) { cleanupTempFile(); res.status(400).json({ success: false, error: 'Only SVG and PNG files are supported.' }); return; }
4683 +
4684 + const iconsRoot = obj.path.join(obj.parent.datapath, 'icons');
4685 + const customDir = obj.path.join(iconsRoot, 'custom');
4686 + const userCustomDir = getCustomIconUserDir(user);
4687 + const userKey = getCustomIconUserKey(user);
4688 + if ((userCustomDir == null) || (userKey == null)) { cleanupTempFile(); res.status(500).json({ success: false, error: 'Unable to prepare user icons directory.' }); return; }
4689 + try { obj.fs.mkdirSync(iconsRoot); } catch (ex) { if (ex.code !== 'EEXIST') { cleanupTempFile(); res.status(500).json({ success: false, error: 'Unable to prepare icons directory.' }); return; } }
4690 + try { obj.fs.mkdirSync(customDir); } catch (ex) { if (ex.code !== 'EEXIST') { cleanupTempFile(); res.status(500).json({ success: false, error: 'Unable to prepare icons directory.' }); return; } }
4691 + try { obj.fs.mkdirSync(userCustomDir); } catch (ex) { if (ex.code !== 'EEXIST') { cleanupTempFile(); res.status(500).json({ success: false, error: 'Unable to prepare user icons directory.' }); return; } }
4692 +
4693 + const previousIcon = (fields && fields.previousIcon && fields.previousIcon[0]) ? fields.previousIcon[0] : null;
4694 + const previousInfo = resolveCustomIconPath(previousIcon, user);
4695 + if ((previousInfo != null) && (previousInfo.isOwned === true)) {
4696 + try { obj.fs.unlinkSync(previousInfo.diskPath); } catch (ex) { }
4697 + }
4698 +
4699 + const newFilename = iconType + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 8) + extension;
4700 + const destinationPath = obj.path.join(userCustomDir, newFilename);
4701 +
4702 + const respondSuccess = function () { res.json({ success: true, path: domain.url + 'icons/custom/' + userKey + '/' + newFilename }); };
4703 +
4704 + obj.fs.rename(iconTempPath, destinationPath, function (renameErr) {
4705 + if (renameErr == null) { respondSuccess(); return; }
4706 + if ((renameErr != null) && (renameErr.code === 'EXDEV')) {
4707 + obj.common.copyFile(iconTempPath, destinationPath, function (copyErr) {
4708 + cleanupTempFile();
4709 + if (copyErr) { res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' }); return; }
4710 + respondSuccess();
4711 + });
4712 + } else {
4713 + cleanupTempFile();
4714 + res.status(500).json({ success: false, error: 'Failed to save uploaded icon.' });
4715 + }
4716 + });
4717 + });
4718 + }
4719 +
4720 + function resolveCustomIconPath(requestPath, user) {
4721 + if (typeof requestPath !== 'string') { return null; }
4722 + if (requestPath.startsWith('http://') || requestPath.startsWith('https://') || requestPath.startsWith('data:')) { return null; }
4723 + const pathOnly = requestPath.split('?')[0].split('#')[0];
4724 + const marker = '/icons/custom/';
4725 + const markerIndex = pathOnly.indexOf(marker);
4726 + if (markerIndex < 0) { return null; }
4727 + const relativePath = pathOnly.substring(markerIndex + marker.length);
4728 + if ((relativePath.length === 0) || (relativePath.indexOf('\\') !== -1)) { return null; }
4729 + const pathParts = relativePath.split('/');
4730 + if ((pathParts.length !== 1) && (pathParts.length !== 2)) { return null; }
4731 + for (var i = 0; i < pathParts.length; i++) {
4732 + if ((pathParts[i].length === 0) || (obj.common.IsFilenameValid(pathParts[i]) !== true)) { return null; }
4733 + }
4734 +
4735 + var ownerKey = null, iconName = null, diskPath = null, isOwned = false;
4736 + const iconsRoot = obj.path.join(obj.parent.datapath, 'icons', 'custom');
4737 + if (pathParts.length === 1) {
4738 + iconName = pathParts[0];
4739 + diskPath = obj.path.join(iconsRoot, iconName);
4740 + } else {
4741 + ownerKey = pathParts[0];
4742 + iconName = pathParts[1];
4743 + diskPath = obj.path.join(iconsRoot, ownerKey, iconName);
4744 + const currentUserKey = getCustomIconUserKey(user);
4745 + isOwned = (currentUserKey != null) && (ownerKey === currentUserKey);
4746 + }
4747 +
4748 + const lower = iconName.toLowerCase();
4749 + if ((lower.endsWith('.svg') === false) && (lower.endsWith('.png') === false)) { return null; }
4750 + return { ownerKey: ownerKey, iconName: iconName, diskPath: diskPath, isOwned: isOwned, isLegacy: (pathParts.length === 1) };
4751 + }
4752 +
4753 + function handleCustomIconDelete(req, res) {
4754 + const domain = checkUserIpAddress(req, res);
4755 + if (domain == null) { return; }
4756 + if ((req.session == null) || (typeof req.session.userid !== 'string')) { res.sendStatus(401); return; }
4757 + const user = obj.users[req.session.userid];
4758 + if (user == null) { res.sendStatus(401); return; }
4759 +
4760 + const iconPath = (req.body && (typeof req.body.iconPath === 'string')) ? req.body.iconPath : null;
4761 + const iconInfo = resolveCustomIconPath(iconPath, user);
4762 + if ((iconInfo == null) || (iconInfo.isOwned !== true)) { res.status(400).json({ success: false, error: 'Invalid icon path.' }); return; }
4763 +
4764 + obj.fs.unlink(iconInfo.diskPath, function (err) {
4765 + if (err && (err.code !== 'ENOENT')) { res.status(500).json({ success: false, error: 'Failed to delete icon.' }); return; }
4766 + res.json({ success: true });
4767 + });
4768 + }
4769 +
4770 + function handleCustomIconDownload(req, res) {
4771 + const domain = getDomain(req);
4772 + if (domain == null) { res.sendStatus(404); return; }
4773 + if ((req.session == null) || (typeof req.session.userid !== 'string')) { res.sendStatus(401); return; }
4774 + const user = obj.users[req.session.userid];
4775 + if (user == null) { res.sendStatus(401); return; }
4776 +
4777 + if ((req.params == null) || (typeof req.params[0] !== 'string')) { res.sendStatus(404); return; }
4778 + const iconInfo = resolveCustomIconPath('/icons/custom/' + req.params[0], user);
4779 + if (iconInfo == null) { res.sendStatus(404); return; }
4780 + if ((iconInfo.isLegacy !== true) && (iconInfo.isOwned !== true)) { res.sendStatus(404); return; }
4781 + const iconNameLower = iconInfo.iconName.toLowerCase();
4782 +
4783 + obj.fs.readFile(iconInfo.diskPath, function (err, data) {
4784 + if (err) { res.sendStatus(404); return; }
4785 + res.set({ 'Content-Type': iconNameLower.endsWith('.png') ? 'image/png' : 'image/svg+xml' });
4786 + res.send(data);
4787 + });
4788 + }
4789 +
4790 function handleUploadFile(req, res) {
4791 const domain = checkUserIpAddress(req, res);
4792 if (domain == null) { return; }
@@ -4646,7 +4828,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4828 var names = fields.name[0].split('*'), sizes = fields.size[0].split('*'), types = fields.type[0].split('*'), datas = fields.data[0].split('*');
4829 if ((names.length == sizes.length) && (types.length == datas.length) && (names.length == types.length)) {
4830 for (var i = 0; i < names.length; i++) {
4649 - if (obj.common.IsFilenameValid(names[i]) == false) { res.sendStatus(404); return; }
4831 + var originalName = names[i];
4832 + var safeName = obj.path.basename(originalName);
4833 + if ((safeName !== originalName) || (obj.common.IsFilenameValid(safeName) == false)) { res.sendStatus(404); return; }
4834 var filedata = Buffer.from(datas[i].split(',')[1], 'base64');
4835 if ((xfile.quota == null) || ((totalsize + filedata.length) < xfile.quota)) { // Check if quota would not be broken if we add this file
4836 // Create the user folder if needed
@@ -4657,7 +4841,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4841 obj.parent.DispatchEvent([user._id], obj, 'updatefiles'); // Fire an event causing this user to update this files
4842 });
4843 });
4660 - })(xfile.fullpath, names[i], filedata);
4844 + })(xfile.fullpath, safeName, filedata);
4845 } else {
4846 // Send a notification
4847 obj.parent.DispatchEvent([user._id], obj, { action: 'notify', title: "Disk quota exceed", value: names[i], nolog: 1, id: Math.random() });
@@ -4666,9 +4850,15 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4850 }
4851 } else {
4852 // More typical upload method, the file data is in a multipart mime post.
4669 - files.files.forEach(function (file) {
4670 - var fpath = obj.path.join(xfile.fullpath, file.originalFilename);
4671 - if (obj.common.IsFilenameValid(file.originalFilename) && ((xfile.quota == null) || ((totalsize + file.size) < xfile.quota))) { // Check if quota would not be broken if we add this file
4853 + for (var i in files.files) {
4854 + var file = files.files[i];
4855 + var originalFilename = (typeof file.originalFilename === 'string') ? file.originalFilename : '';
4856 + var safeOriginalFilename = obj.path.basename(originalFilename);
4857 + var isFilenameAcceptable = (safeOriginalFilename === originalFilename) && obj.common.IsFilenameValid(safeOriginalFilename);
4858 + const uploadTempPath = resolveSafeUploadTempPath(file.path);
4859 + if (uploadTempPath == null) { res.sendStatus(400); return; }
4860 + if (isFilenameAcceptable && ((xfile.quota == null) || ((totalsize + file.size) < xfile.quota))) { // Check if quota would not be broken if we add this file
4861 + var fpath = obj.path.join(xfile.fullpath, safeOriginalFilename);
4862
4863 // See if we need to create the folder
4864 var domainx = 'domain';
@@ -4678,11 +4868,11 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4868 try { obj.fs.mkdirSync(xfile.fullpath); } catch (e) { }
4869
4870 // Rename the file
4681 - obj.fs.rename(file.path, fpath, function (err) {
4871 + obj.fs.rename(uploadTempPath, fpath, function (err) {
4872 if (err && (err.code === 'EXDEV')) {
4873 // On some Linux, the rename will fail with a "EXDEV" error, do a copy+unlink instead.
4684 - obj.common.copyFile(file.path, fpath, function (err) {
4685 - obj.fs.unlink(file.path, function (err) {
4874 + obj.common.copyFile(uploadTempPath, fpath, function (err) {
4875 + obj.fs.unlink(uploadTempPath, function (err) {
4876 obj.parent.DispatchEvent([user._id], obj, 'updatefiles'); // Fire an event causing this user to update this files
4877 });
4878 });
@@ -4693,9 +4883,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4883 } else {
4884 // Send a notification
4885 obj.parent.DispatchEvent([user._id], obj, { action: 'notify', title: "Disk quota exceed", value: file.originalFilename, nolog: 1, id: Math.random() });
4696 - try { obj.fs.unlink(file.path, function (err) { }); } catch (e) { }
4886 + try { obj.fs.unlink(uploadTempPath, function (err) { }); } catch (e) { }
4887 }
4698 - });
4888 + }
4889 }
4890 } else {
4891 // Send a notification
@@ -4747,17 +4937,21 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
4937 try { obj.fs.mkdirSync(serverpath); } catch (ex) { }
4938
4939 // More typical upload method, the file data is in a multipart mime post.
4750 - files.files.forEach(function (file) {
4751 - var ftarget = getRandomPassword() + '-' + file.originalFilename, fpath = obj.path.join(serverpath, ftarget);
4940 + for (var i in files.files) {
4941 + var file = files.files[i];
4942 + const ftarget = getRandomPassword() + '-' + file.originalFilename;
4943 + const targetPath = obj.path.join(serverpath, ftarget);
4944 + const uploadTempPath = resolveSafeUploadTempPath(file.path);
4945 + if (uploadTempPath == null) { res.sendStatus(400); return; }
4946 cmd.files.push({ name: file.originalFilename, target: ftarget });
4947 // Rename the file
4754 - obj.fs.rename(file.path, fpath, function (err) {
4948 + obj.fs.rename(uploadTempPath, targetPath, function (err) {
4949 if (err && (err.code === 'EXDEV')) {
4950 // On some Linux, the rename will fail with a "EXDEV" error, do a copy+unlink instead.
4757 - obj.common.copyFile(file.path, fpath, function (err) { obj.fs.unlink(file.path, function (err) { }); });
4951 + obj.common.copyFile(uploadTempPath, targetPath, function (err) { obj.fs.unlink(uploadTempPath, function (err) { }); });
4952 }
4953 });
4760 - });
4954 + }
4955
4956 // Instruct one of more agents to download a URL to a given local drive location.
4957 var tlsCertHash = null;
@@ -5094,9 +5288,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5288 }
5289
5290 // Close the recording file
5097 - if (ws.logfile != null) {
5291 + if (ws.logfile != null) {
5292 setTimeout(function(){ // wait 5 seconds before finishing file for some reason?
5099 - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5293 + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5294 obj.fs.close(logfile.fd);
5295 parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename);
5296 // Compute session length
@@ -5148,7 +5342,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5342 // Close the recording file
5343 if (ws.logfile != null) {
5344 setTimeout(function(){ // wait 5 seconds before finishing file for some reason?
5151 - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5345 + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5346 obj.fs.close(logfile.fd);
5347 parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename);
5348 // Compute session length
@@ -5224,7 +5418,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5418 // Close the recording file
5419 if (ws.logfile != null) {
5420 setTimeout(function(){ // wait 5 seconds before finishing file for some reason?
5227 - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5421 + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5422 obj.fs.close(logfile.fd);
5423 parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename);
5424 // Compute session length
@@ -5266,7 +5460,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5460 // Close the recording file
5461 if (ws.logfile != null) {
5462 setTimeout(function(){ // wait 5 seconds before finishing file for some reason?
5269 - obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5463 + obj.meshRelayHandler.recordingEntry(ws.logfile, 3, 0, 'MeshCentralMCREC', function (logfile, ws) {
5464 obj.fs.close(logfile.fd);
5465 parent.debug('relay', 'Relay: Finished recording to file: ' + ws.logfile.filename);
5466 // Compute session length
@@ -5369,7 +5563,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5563 if (req.query.p == 2) { // Only log event if Intel Redirection, otherwise hundreds of logs for WSMAN are recorded
5564 var msg = 'Started relay session', msgid = 13, ip = ((ciraconn != null) ? ciraconn.remoteAddr : (((conn & 4) != 0) ? node.host : req.clientIp));
5565 var event = { etype: 'relay', action: 'relaylog', domain: domain.id, userid: user._id, username: user.name, msgid: msgid, msgArgs: [ws.id, req.clientIp, ip], msg: msg + ' \"' + ws.id + '\" from ' + req.clientIp + ' to ' + ip, protocol: 101, nodeid: node._id };
5372 - obj.parent.DispatchEvent(['*', user._id], obj, event);
5566 + obj.parent.DispatchEvent(['*', user._id], obj, event);
5567 }
5568
5569 // Update user last access time
@@ -5682,7 +5876,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5876 if ((user == null) || ((user.siteadmin & 1) == 0)) { res.sendStatus(401); return; } // Check if we have server backup rights
5877
5878 // Require modules
5685 - const archive = require('archiver')('zip', { level: 9 }); // Sets the compression method to maximum.
5879 + const archive = require('archiver')('zip', { level: 9 }); // Sets the compression method to maximum.
5880
5881 // Good practice to catch this error explicitly
5882 archive.on('error', function (err) { throw err; });
@@ -5690,13 +5884,13 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
5884 // Set the archive name
5885 res.attachment((domain.title ? domain.title : 'MeshCentral') + '-Backup-' + new Date().toLocaleDateString().replace('/', '-').replace('/', '-') + '.zip');
5886
5693 - // Pipe archive data to the file
5887 + // Pipe archive data to the file
5888 archive.pipe(res);
5889
5890 // Append files from a glob pattern
5891 archive.directory(obj.parent.datapath, false);
5892
5699 - // Finalize the archive (ie we are done appending files but streams have to finish yet)
5893 + // Finalize the archive (ie we are done appending files but streams have to finish yet)
5894 archive.finalize();
5895 }
5896
@@ -6845,6 +7039,15 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7039 var selfurl = ' wss://' + req.headers.host;
7040 if ((xforwardedhost != null) && (xforwardedhost != req.headers.host)) { selfurl += ' wss://' + xforwardedhost; }
7041 const extraScriptSrc = (parent.config.settings.extrascriptsrc != null) ? (' ' + parent.config.settings.extrascriptsrc) : '';
7042 + const extraImgSrc = (parent.config.settings.extraimgsrc != null) ? (' ' + parent.config.settings.extraimgsrc) : '';
7043 + const allowedFramingOriginsValue = (domain.allowedframingorigins != null) ? domain.allowedframingorigins : parent.config.settings.allowedframingorigins;
7044 + const hasAllowedFramingOrigins = (allowedFramingOriginsValue != null);
7045 + var framingOrigins = [];
7046 + if (typeof allowedFramingOriginsValue === 'string') {
7047 + framingOrigins = allowedFramingOriginsValue.split(/[,\s]+/).map(function (v) { return v.trim(); }).filter(function (v) { return v.length > 0; });
7048 + } else if (Array.isArray(allowedFramingOriginsValue)) {
7049 + framingOrigins = allowedFramingOriginsValue.filter(function (v) { return (typeof v === 'string') && (v.trim().length > 0); }).map(function (v) { return v.trim(); });
7050 + }
7051
7052 // If the web relay port is enabled, allow the web page to redirect to it
7053 var extraFrameSrc = '';
@@ -6852,7 +7055,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7055 extraFrameSrc = ' https://' + req.headers.host + ':' + parent.webrelayserver.port;
7056 if ((xforwardedhost != null) && (xforwardedhost != req.headers.host)) { extraFrameSrc += ' https://' + xforwardedhost + ':' + parent.webrelayserver.port; }
7057 }
6855 -
7058 +
7059
7060 // If using duo add apihostname to CSP
7061 var duoSrc = '';
@@ -6860,24 +7063,6 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7063 duoSrc = domain.duo2factor.apihostname;
7064 }
7065
6863 - // If a custom OIDC button icon URL is configured, allow its origin in img-src CSP
6864 - var extraImgSrc = '';
6865 - if (obj.common.validateObject(domain.authstrategies) && obj.common.validateObject(domain.authstrategies.oidc) && obj.common.validateObject(domain.authstrategies.oidc.custom)) {
6866 - const seen = {};
6867 - const urls = [domain.authstrategies.oidc.custom.buttoniconurl, domain.authstrategies.oidc.custom.buttoniconurl2x];
6868 - for (var k = 0; k < urls.length; k++) {
6869 - if (obj.common.validateUrl(urls[k])) {
6870 - try { const u = new URL(urls[k]); if (!seen[u.origin]) { extraImgSrc += ' ' + u.origin; seen[u.origin] = true; } } catch (e) {}
6871 - }
6872 - }
6873 - }
6874 -
6875 - // allowedFramingOrigins: domain override, else settings
6876 - var allowedFramingOriginsVal = (domain != null && domain.allowedframingorigins != null) ? domain.allowedframingorigins : parent.config.settings.allowedframingorigins;
6877 - var framingOrigins = parseAllowedFramingOrigins(allowedFramingOriginsVal);
6878 - var hasAllowedFramingOrigins = (domain != null && domain.allowedframingorigins != null) || (parent.config.settings.allowedframingorigins != null);
6879 -
6880 -
7066 // Finish setup security headers
7067 var cspBase = "default-src 'none'; font-src 'self' fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' " + extraScriptSrc + "; connect-src 'self'" + geourl + selfurl + "; img-src 'self' blob: data:" + geourl + extraImgSrc + " data:; style-src 'self' 'unsafe-inline' fonts.googleapis.com; frame-src 'self' blob: mcrouter:" + extraFrameSrc + "; media-src 'self'; form-action 'self' " + duoSrc + "; manifest-src 'self'";
7068 if (hasAllowedFramingOrigins) {
@@ -7043,6 +7228,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7228 obj.app.get(url + 'commander.ashx', handleMeshCommander);
7229 obj.app.post(url + 'uploadfile.ashx', obj.bodyParser.urlencoded({ extended: false }), handleUploadFile);
7230 obj.app.post(url + 'uploadfilebatch.ashx', obj.bodyParser.urlencoded({ extended: false }), handleUploadFileBatch);
7231 + obj.app.post(url + 'customiconupload.ashx', handleCustomIconUpload);
7232 + obj.app.post(url + 'customicondelete.ashx', obj.bodyParser.urlencoded({ extended: false }), handleCustomIconDelete);
7233 + obj.app.get(url + 'icons/custom/*', handleCustomIconDownload);
7234 obj.app.post(url + 'uploadmeshcorefile.ashx', obj.bodyParser.urlencoded({ extended: false }), handleUploadMeshCoreFile);
7235 obj.app.post(url + 'oneclickrecovery.ashx', obj.bodyParser.urlencoded({ extended: false }), handleOneClickRecoveryFile);
7236 obj.app.get(url + 'userfiles/*', handleDownloadUserFiles);
@@ -7117,7 +7305,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7305 }
7306 obj.app.get(url + 'invite', handleInviteRequest);
7307 obj.app.post(url + 'invite', obj.bodyParser.urlencoded({ extended: false }), handleInviteRequest);
7120 -
7308 +
7309 if (parent.pluginHandler != null) {
7310 obj.app.get(url + 'pluginadmin.ashx', obj.handlePluginAdminReq);
7311 obj.app.post(url + 'pluginadmin.ashx', obj.bodyParser.urlencoded({ extended: false }), obj.handlePluginAdminPostReq);
@@ -7442,7 +7630,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7630 // Notify account 2fa failed login
7631 const ua = obj.getUserAgentInfo(req);
7632 obj.parent.DispatchEvent(['*', 'server-users', user._id], obj, { action: 'authfail', username: user.name, userid: user._id, domain: domain.id, msg: 'User login attempt with incorrect 2nd factor from ' + req.clientIp, msgid: 108, msgArgs: [req.clientIp, ua.browserStr, ua.osStr] });
7445 - obj.setbad2Fa(req);
7633 + obj.setbad2Fa(req);
7634 res.redirect(domain.url + getQueryPortion(req));
7635 });
7636 } else {
@@ -7800,7 +7988,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
7988 parent.debug('web', '404 Error ' + req.url);
7989 var domain = getDomain(req);
7990 if ((domain == null) || (domain.auth == 'sspi')) { res.sendStatus(404); return; }
7803 - if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL
7991 + if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL
7992 const cspNonce = obj.crypto.randomBytes(15).toString('base64');
7993 res.set({ 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'nonce-" + cspNonce + "'; img-src 'self'; style-src 'self' 'nonce-" + cspNonce + "';" }); // This page supports very tight CSP policy
7994 res.status(404).render(getRenderPage((domain.sitestyle >= 2) ? 'error4042' : 'error404', req, domain), getRenderArgs({ cspNonce: cspNonce }, req, domain));
@@ -7822,7 +8010,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
8010 parent.debug('web', '404 Error ' + req.url);
8011 var domain = getDomain(req);
8012 if ((domain == null) || (domain.auth == 'sspi')) { res.sendStatus(404); return; }
7825 - if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL
8013 + if ((domain.loginkey != null) && (domain.loginkey.indexOf(req.query.key) == -1)) { res.sendStatus(404); return; } // Check 3FA URL
8014 if (obj.args.nice404 == false) { res.sendStatus(404); return; }
8015 const cspNonce = obj.crypto.randomBytes(15).toString('base64');
8016 res.set({ 'Content-Security-Policy': "default-src 'none'; script-src 'self' 'nonce-" + cspNonce + "'; img-src 'self'; style-src 'self' 'nonce-" + cspNonce + "';" }); // This page supports very tight CSP policy
@@ -8363,7 +8551,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
8551 if (Buffer.isBuffer(data[0])) {
8552 data = Buffer.concat(data);
8553 data = data.toString();
8366 - } else { // else if (typeof data[0] == 'string')
8554 + } else { // else if (typeof data[0] == 'string')
8555 data = data.join();
8556 }
8557 } catch (err) {
@@ -8418,7 +8606,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
8606 return authStrategyFlags;
8607 }
8608
8421 - // Handle an incoming request as a web relay
8609 + // Handle an incoming request as a web relay
8610 function handleWebRelayRequest(req, res) {
8611 var webRelaySessionId = null;
8612 if ((req.session.userid != null) && (req.session.x != null)) { webRelaySessionId = req.session.userid + '/' + req.session.x; }
@@ -8438,7 +8626,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
8626 }
8627 }
8628
8441 - // Handle an incoming websocket connection as a web relay
8629 + // Handle an incoming websocket connection as a web relay
8630 function handleWebRelayWebSocket(ws, req) {
8631 var webRelaySessionId = null;
8632 if ((req.session.userid != null) && (req.session.x != null)) { webRelaySessionId = req.session.userid + '/' + req.session.x; }
@@ -8882,7 +9070,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
9070 if (emailcheck && (user.email != null) && (!(user._id.split('/')[2].startsWith('~'))) && (user.emailVerified !== true)) {
9071 parent.debug('web', 'Invalid login, asking for email validation');
9072 try { ws.send(JSON.stringify({ action: 'close', cause: 'emailvalidation', msg: 'emailvalidationrequired', email2fa: email2fa, email2fasent: true })); ws.close(); } catch (e) { }
8885 - } else {
9073 + } else {
9074 req.session.userid = user._id;
9075 req.session.ip = req.clientIp;
9076 setSessionRandom(req);
@@ -9797,7 +9985,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
9985 xargs.title1 = domain.title1 ? domain.title1 : '';
9986 xargs.title2 = (domain.title1 && domain.title2) ? domain.title2 : '';
9987 }
9800 - xargs.title2 = obj.common.replacePlaceholders(xargs.title2, {
9988 + xargs.title2 = obj.common.replacePlaceholders(xargs.title2, {
9989 'serverversion': obj.parent.currentVer,
9990 'servername': obj.getWebServerName(domain, req),
9991 'agentsessions': Object.keys(parent.webserver.wsagents).length,
@@ -10171,7 +10359,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
10359 if (ua.browser && ua.browser.name) { ua.browserStr = ua.browser.name; if (ua.browser.version) { ua.browserStr += '/' + ua.browser.version } }
10360 if (ua.os && ua.os.name) { ua.osStr = ua.os.name; if (ua.os.version) { ua.osStr += '/' + ua.os.version } }
10361 // If the platform is set, use that instead of the OS
10174 - if (ua.platform) {
10362 + if (ua.platform) {
10363 ua.osStr = ua.platform;
10364 // Special case for Windows 11
10365 if (ua.platformVersion) {
@@ -10185,9 +10373,9 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
10373 } catch (ex) { return { browserStr: browser, osStr: os } }
10374 }
10375
10188 - // Return the query string portion of the URL, the ? and anything after BUT remove secret keys from authentication providers
10376 + // Return the query string portion of the URL, the ? and anything after BUT remove secret keys from authentication providers
10377 function getQueryPortion(req) {
10190 - var removeKeys = ['duo_code', 'state']; // Keys to remove
10378 + var removeKeys = ['duo_code', 'state']; // Keys to remove
10379 var s = req.url.indexOf('?');
10380 if (s == -1) {
10381 if (req.body && req.body.urlargs) {
@@ -10276,7 +10464,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
10464 if (parent.config.settings.maxinvalidlogin === false) return true;
10465 if (typeof ip == 'object') { ip = ip.clientIp; }
10466 var splitip = ip.split('.');
10279 - if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first
10467 + if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first
10468 var cutoffTime = Date.now() - (parent.config.settings.maxinvalidlogin.time * 60000); // Time in minutes
10469 var ipTable = obj.badLoginTable[ip];
10470 if (ipTable == null) return true;
@@ -10334,7 +10522,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
10522 if (parent.config.settings.maxinvalid2fa === false) return true;
10523 if (typeof ip == 'object') { ip = ip.clientIp; }
10524 var splitip = ip.split('.');
10337 - if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first
10525 + if (splitip.length == 4) { ip = (splitip[0] + '.' + splitip[1] + '.' + splitip[2] + '.*'); } // If this is IPv4, keep only the 3 first
10526 var cutoffTime = Date.now() - (parent.config.settings.maxinvalid2fa.time * 60000); // Time in minutes
10527 var ipTable = obj.bad2faTable[ip];
10528 if (ipTable == null) return true;