From e1280e8cc71dce077ae8287094123dd500bd3818 Mon Sep 17 00:00:00 2001 From: Adcbda Date: Wed, 8 Jul 2026 04:41:37 +0800 Subject: [PATCH] Add custom icon support for web links (#3325) * feat: add custom web link icon support and context menu option Introduce the ability to change and display custom icons for .weblink files. Added a "Change Icon" context menu item that invokes the changeWeblinkIcon helper, and updated the icon rendering logic to use getWeblinkIcon instead of the default link icon. This allows users to personalize web link appearance in the file manager. * Fix security and robustness issues in web link custom icons # UIWindowSearch and require the icon data URL body to be pure base64 in #isValidWeblinkIcon. A shared/downloaded .weblink is untrusted input and # the previous prefix-only check let a crafted icon break out of the # attribute. # byte-signature MIME sniffing and capping stored icon size (was storing # full-resolution images as base64). # without picking a file. # weblinks no longer fetches every file's contents on each render. # path instead of the target weblink). # label, add the AGPL license header, and drop redundant stored icon # copies. * Keep SVG weblink icons as vectors instead of rasterizing Rasterizing a picked SVG to a 256px PNG threw away its scalability for no real benefit: an SVG rendered in runs in script-free static mode, and the base64-body validation already prevents attribute injection. Store SVGs as data:image/svg+xml;base64 (content-sniffed, size-capped), and keep rasterizing raster formats and oversized SVGs to bound stored size. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Daniel Salazar Co-authored-by: Claude Fable 5 --- src/gui/src/UI/UIItem.js | 9 + src/gui/src/UI/UIWindowSearch.js | 2 +- .../src/helpers/generate_file_context_menu.js | 10 + src/gui/src/helpers/item_icon.js | 3 +- src/gui/src/helpers/new_context_menu_item.js | 27 +- src/gui/src/helpers/weblink.js | 347 ++++++++++++++++++ src/gui/src/i18n/translations/en.js | 1 + 7 files changed, 381 insertions(+), 18 deletions(-) create mode 100644 src/gui/src/helpers/weblink.js diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index c0bf57162..005c51cf3 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -30,6 +30,7 @@ import truncate_filename from '../helpers/truncate_filename.js'; import launch_app from '../helpers/launch_app.js'; import open_item from '../helpers/open_item.js'; import mime from '../lib/mime.js'; +import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js'; const AI_APP_NAME = 'ai'; @@ -1143,6 +1144,8 @@ async function UIItem (options) { // ------------------------------------------------------- else { const is_trash = $(el_item).attr('data-path') === window.trash_path || $(el_item).attr('data-shortcut_to_path') === window.trash_path; + const is_shortcut = !! $(el_item).attr('data-shortcut_to_path'); + const is_weblink = isWeblinkName($(el_item).attr('data-name')); menu_items = []; // ------------------------------------------- // Open @@ -1570,6 +1573,12 @@ async function UIItem (options) { }); } // ------------------------------------------- + // Change Web Link Icon + // ------------------------------------------- + if ( !is_trashed && !is_trash && !is_shortcut && is_weblink ) { + menu_items.push(weblinkChangeIconMenuItem(el_item)); + } + // ------------------------------------------- // Delete // ------------------------------------------- if ( $(el_item).attr('data-immutable') === '0' && !is_trashed ) { diff --git a/src/gui/src/UI/UIWindowSearch.js b/src/gui/src/UI/UIWindowSearch.js index 28ca056a2..01ecb2699 100644 --- a/src/gui/src/UI/UIWindowSearch.js +++ b/src/gui/src/UI/UIWindowSearch.js @@ -138,7 +138,7 @@ async function UIWindowSearch (options) { data-is_dir="${html_encode(result.is_dir)}" >`; // icon - h += ``; + h += ``; h += html_encode(result.name); h += ''; } diff --git a/src/gui/src/helpers/generate_file_context_menu.js b/src/gui/src/helpers/generate_file_context_menu.js index 0009f8c1a..80fe54ab9 100644 --- a/src/gui/src/helpers/generate_file_context_menu.js +++ b/src/gui/src/helpers/generate_file_context_menu.js @@ -28,6 +28,7 @@ import open_item from './open_item.js'; import launch_app from './launch_app.js'; import path from '../lib/path.js'; import mime from '../lib/mime.js'; +import { isWeblinkName, weblinkChangeIconMenuItem } from './weblink.js'; const AI_APP_NAME = 'ai'; @@ -144,6 +145,8 @@ const generate_file_context_menu = async function (options) { const is_trashed = options.is_trashed ?? false; const is_worker = options.is_worker ?? false; const onOpen = options.onOpen; + const is_weblink = isWeblinkName(fsentry.name ?? $(el_item).attr('data-name')); + const is_shortcut = fsentry.is_shortcut || !! $(el_item).attr('data-shortcut_to_path'); let menu_items = []; @@ -522,6 +525,13 @@ const generate_file_context_menu = async function (options) { }); } + // ------------------------------------------- + // Change Web Link Icon + // ------------------------------------------- + if ( !is_trashed && !is_trash && !is_shortcut && is_weblink ) { + menu_items.push(weblinkChangeIconMenuItem(el_item)); + } + // ------------------------------------------- // Delete // ------------------------------------------- diff --git a/src/gui/src/helpers/item_icon.js b/src/gui/src/helpers/item_icon.js index 746125ad8..2783af554 100644 --- a/src/gui/src/helpers/item_icon.js +++ b/src/gui/src/helpers/item_icon.js @@ -19,6 +19,7 @@ import mime from '../lib/mime.js'; import content_type_to_icon from './content_type_to_icon.js'; +import { getWeblinkIcon } from './weblink.js'; /** * Assigns an icon to a filesystem entry based on its properties such as name, type, @@ -240,7 +241,7 @@ const item_icon = async (fsentry) => { } // *.weblink else if ( fsentry.name.toLowerCase().endsWith('.weblink') ) { - return { image: window.icons['link.svg'], type: 'icon' }; + return { image: await getWeblinkIcon(fsentry), type: 'icon' }; } // *.tar else if ( fsentry.name.toLowerCase().endsWith('.tar') ) { diff --git a/src/gui/src/helpers/new_context_menu_item.js b/src/gui/src/helpers/new_context_menu_item.js index 211305b80..82fe452fb 100644 --- a/src/gui/src/helpers/new_context_menu_item.js +++ b/src/gui/src/helpers/new_context_menu_item.js @@ -19,6 +19,7 @@ import UIPrompt from '../UI/UIPrompt.js'; import UIAlert from '../UI/UIAlert.js'; +import { createWeblinkData, defaultWeblinkIcon } from './weblink.js'; /** * Returns a context menu item to create a new folder and a variety of file types. @@ -93,20 +94,14 @@ const new_context_menu_item = function (dirname, append_to_element) { let linkName = siteName; let fileName = `${linkName }.weblink`; - // Store the URL in a simple JSON object - const weblink_content = JSON.stringify({ + const icon = defaultWeblinkIcon(); + const weblink_content = JSON.stringify(createWeblinkData({ url: url, - type: 'weblink', domain: domain, - created: Date.now(), - modified: Date.now(), - version: '2.0', - metadata: { - originalUrl: url, - linkName: linkName, - simpleName: siteName, - }, - }); + linkName: linkName, + simpleName: siteName, + icon: icon, + })); // Create the file with standard link icon const item = await window.create_file({ @@ -114,17 +109,17 @@ const new_context_menu_item = function (dirname, append_to_element) { append_to_element: append_to_element, name: fileName, content: weblink_content, - icon: window.icons['link.svg'], + icon: icon, type: 'weblink', metadata: JSON.stringify({ url: url, domain: domain, timestamp: Date.now(), - version: '2.0', + version: '2.1', }), html_attributes: { 'data-weblink': 'true', - 'data-icon': window.icons['link.svg'], + 'data-icon': icon, 'data-url': url, 'data-domain': domain, 'data-display-name': linkName, @@ -222,4 +217,4 @@ router.get('/*page', ({request, params}) => { }; }; -export default new_context_menu_item; \ No newline at end of file +export default new_context_menu_item; diff --git a/src/gui/src/helpers/weblink.js b/src/gui/src/helpers/weblink.js new file mode 100644 index 000000000..1a39562db --- /dev/null +++ b/src/gui/src/helpers/weblink.js @@ -0,0 +1,347 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import UIWindow from '../UI/UIWindow.js'; +import UIAlert from '../UI/UIAlert.js'; + +// Icons are stored inline in the .weblink JSON as base64 data URLs. Because a +// .weblink file can be shared or downloaded, its icon field is untrusted input +// and is re-validated with isValidWeblinkIcon() every time it is read. +const WEBLINK_ICON_ALLOWLIST = [ + 'data:image/png;base64,', + 'data:image/jpeg;base64,', + 'data:image/gif;base64,', + 'data:image/webp;base64,', + 'data:image/svg+xml;base64,', +]; + +// Raster icons are downscaled to a small PNG before storing so the icon stays +// a few KB rather than embedding a full-resolution photo into the file/DOM. +const WEBLINK_ICON_MAX_DIMENSION = 256; + +// SVG icons are kept as vectors, but capped so a pathological SVG (e.g. one +// with an embedded base64 raster) can't bloat the file; oversized ones fall +// back to rasterization. +const WEBLINK_ICON_MAX_SVG_BYTES = 512 * 1024; + +const WEBLINK_VERSION = '2.1'; + +// Cache the resolved icon per file so a folder full of weblinks doesn't fetch +// every file's contents on each render/refresh. Keyed by path; entries are +// refreshed when changeWeblinkIcon writes a new icon. +const weblinkIconCache = new Map(); + +export const defaultWeblinkIcon = () => window.icons['link.svg']; + +export const isWeblinkName = (name) => + typeof name === 'string' && name.toLowerCase().endsWith('.weblink'); + +export const isValidWeblinkIcon = (icon) => { + if ( typeof icon !== 'string' || icon.length === 0 ) { + return false; + } + + if ( icon === defaultWeblinkIcon() ) { + return true; + } + + const lower = icon.toLowerCase(); + const prefix = WEBLINK_ICON_ALLOWLIST.find(p => lower.startsWith(p)); + if ( !prefix ) { + return false; + } + + // The body must be pure base64. This rejects anything containing a quote, + // space or angle bracket, which is what stops a crafted icon value from + // breaking out of an `` attribute (DOM XSS). + const body = icon.slice(prefix.length); + return body.length > 0 && /^[a-z0-9+/]+={0,2}$/i.test(body); +}; + +export const createWeblinkData = ({ url, domain, linkName, simpleName, icon = defaultWeblinkIcon() }) => ({ + url: url, + type: 'weblink', + domain: domain, + icon: isValidWeblinkIcon(icon) ? icon : defaultWeblinkIcon(), + created: Date.now(), + modified: Date.now(), + version: WEBLINK_VERSION, + metadata: { + originalUrl: url, + linkName: linkName, + simpleName: simpleName, + }, +}); + +export const parseWeblinkData = async (content) => { + const text = typeof content === 'string' ? content : await content.text(); + + try { + return JSON.parse(text); + } catch (e) { + if ( text.startsWith('http://') || text.startsWith('https://') ) { + const url = new URL(text); + const domain = url.hostname; + const simpleName = domain.replace(/^www\./, '').split('.')[0]; + const linkName = simpleName.charAt(0).toUpperCase() + simpleName.slice(1); + + return createWeblinkData({ + url: text, + domain: domain, + linkName: linkName, + simpleName: simpleName, + }); + } + + throw e; + } +}; + +export const readWeblinkData = async (path) => { + const content = await puter.fs.read({ path: path }); + return parseWeblinkData(content); +}; + +const readFileAsDataUrl = async (file) => await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); +}); + +// Read the first bytes of a blob as text so we can sniff SVG source. Returns '' +// if the blob doesn't support slicing (nothing is treated as SVG in that case). +const readHead = async (file) => { + if ( !file?.slice || !file?.arrayBuffer ) { + return ''; + } + try { + return new TextDecoder('utf-8', { fatal: false }).decode(await file.slice(0, 512).arrayBuffer()); + } catch (e) { + return ''; + } +}; + +// Rasterize any browser-decodable image down to a small PNG data URL. Used for +// raster formats (and as a size-capping fallback for oversized SVGs); it bounds +// the stored size and yields a single known-safe type. +const rasterizeToPngDataUrl = async (dataUrl) => await new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + try { + const srcW = img.naturalWidth || img.width || WEBLINK_ICON_MAX_DIMENSION; + const srcH = img.naturalHeight || img.height || WEBLINK_ICON_MAX_DIMENSION; + const scale = Math.min(1, WEBLINK_ICON_MAX_DIMENSION / Math.max(srcW, srcH)); + const w = Math.max(1, Math.round(srcW * scale)); + const h = Math.max(1, Math.round(srcH * scale)); + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + canvas.getContext('2d').drawImage(img, 0, 0, w, h); + + resolve(canvas.toDataURL('image/png')); + } catch (e) { + reject(e); + } + }; + img.onerror = () => reject(new Error('Please choose a PNG, JPG, GIF, WebP, or SVG image.')); + img.src = dataUrl; +}); + +const readIconFromFsEntry = async (fsentry) => { + const file = await puter.fs.read(fsentry.path); + const head = await readHead(file); + + // Keep SVGs as vectors — they stay crisp at any size/DPI and render + // script-free in . Rasterize everything else (and oversized SVGs) to a + // small PNG so the stored icon stays a few KB. + if ( head.toLowerCase().includes(' await new Promise((resolve, reject) => { + const $parentWindow = $(elItem).closest('.window'); + const hasParentWindow = $parentWindow.length > 0; + + let $receiver; + let parentUuid; + if ( hasParentWindow ) { + $receiver = $parentWindow; + parentUuid = $parentWindow.attr('data-element_uuid'); + } else { + parentUuid = `weblink-icon-picker-${Date.now()}-${Math.random().toString(36).slice(2)}`; + $receiver = $('
') + .addClass('window') + .attr('data-element_uuid', parentUuid) + .css('display', 'none') + .appendTo('body'); + } + + let settled = false; + let filePicked = false; + + const cleanup = () => { + $receiver.off('file_opened', onFileOpened); + if ( !hasParentWindow ) { + $receiver.remove(); + } + }; + + const finish = (fn, value) => { + if ( settled ) return; + settled = true; + cleanup(); + fn(value); + }; + + async function onFileOpened (e) { + // Set synchronously (before the first await) so on_close, which fires + // right after selection, does not resolve null and discard the result. + filePicked = true; + try { + const selectedFile = Array.isArray(e.detail) ? e.detail[0] : e.detail; + + if ( !selectedFile?.path ) { + finish(resolve, null); + return; + } + + finish(resolve, await readIconFromFsEntry(selectedFile)); + } catch (error) { + finish(reject, error); + } + } + + $receiver.on('file_opened', onFileOpened); + + UIWindow({ + path: `/${window.user.username}/Desktop`, + parent_uuid: parentUuid, + parent_center: hasParentWindow, + center: !hasParentWindow, + allowed_file_types: 'image/*', + show_maximize_button: false, + show_minimize_button: false, + title: i18n('window_title_open'), + is_dir: true, + is_openFileDialog: true, + selectable_body: false, + backdrop: true, + close_on_backdrop_click: true, + stay_on_top: true, + // Fires on any dismissal (cancel button, X, backdrop, Escape). + on_close: () => { + if ( !filePicked ) finish(resolve, null); + }, + }).catch((error) => finish(reject, error)); +}); + +export const updateWeblinkIcon = async ({ path, icon }) => { + const data = await readWeblinkData(path); + data.icon = icon; + data.modified = Date.now(); + data.version = WEBLINK_VERSION; + + await puter.fs.write(path, JSON.stringify(data), { overwrite: true }); + return data; +}; + +export const changeWeblinkIcon = async (elItem) => { + const $item = $(elItem); + const icon = await chooseWeblinkIcon(elItem); + + if ( !icon ) { + return null; + } + + const path = $item.attr('data-path'); + await updateWeblinkIcon({ path, icon }); + + // Update every live view of this item (Desktop + any open folder windows), + // not just the clicked one, so the icon doesn't look stale elsewhere. + const uid = $item.attr('data-uid'); + const $views = uid ? $(`.item[data-uid="${uid}"]`) : $item; + $views.find('.item-icon > img').attr('src', icon); + $views.attr('data-icon', icon); + + weblinkIconCache.set(path, icon); + + return icon; +}; + +export const getWeblinkIcon = async (fsentry) => { + const path = fsentry.path; + + if ( !path ) { + // Avoid a doomed puter.fs.read({ path: undefined }) for listing entries + // that don't carry a path. + return defaultWeblinkIcon(); + } + + if ( weblinkIconCache.has(path) ) { + return weblinkIconCache.get(path); + } + + try { + const data = await readWeblinkData(path); + const icon = data.icon ?? data.metadata?.icon; + + if ( isValidWeblinkIcon(icon) ) { + weblinkIconCache.set(path, icon); + return icon; + } + } catch (e) { + // Older weblinks may contain only a URL or malformed legacy JSON. + } + + return defaultWeblinkIcon(); +}; + +// Shared "Change Icon" context-menu entry so UIItem and generate_file_context_menu +// stay in sync instead of duplicating the block. +export const weblinkChangeIconMenuItem = (elItem) => ({ + html: i18n('change_icon'), + onClick: async function () { + try { + await changeWeblinkIcon(elItem); + } catch (error) { + UIAlert(error.message ?? 'Could not change the web link icon.'); + } + }, +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 55155441f..98e22d8d8 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -96,6 +96,7 @@ const en = { cpu: 'CPU', create_account: 'Create Account', create_free_account: 'Create Free Account', + change_icon: 'Change Icon', create_desktop_shortcut: 'Create Shortcut (Desktop)', create_desktop_shortcut_s: 'Create Shortcuts (Desktop)', create_shortcut: 'Create Shortcut',