mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-25 06:36:00 +00:00
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 # <img src> 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 <img> 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 <noreply@anthropic.com> --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Daniel Salazar
parent
70884c2e30
commit
e1280e8cc7
@@ -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 ) {
|
||||
|
||||
@@ -138,7 +138,7 @@ async function UIWindowSearch (options) {
|
||||
data-is_dir="${html_encode(result.is_dir)}"
|
||||
>`;
|
||||
// icon
|
||||
h += `<img src="${(await item_icon(result)).image}" style="width: 20px; height: 20px; margin-right: 6px;">`;
|
||||
h += `<img src="${html_encode((await item_icon(result)).image)}" style="width: 20px; height: 20px; margin-right: 6px;">`;
|
||||
h += html_encode(result.name);
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------
|
||||
|
||||
@@ -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') ) {
|
||||
|
||||
@@ -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;
|
||||
export default new_context_menu_item;
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 `<img src="...">` 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 <img>. Rasterize everything else (and oversized SVGs) to a
|
||||
// small PNG so the stored icon stays a few KB.
|
||||
if ( head.toLowerCase().includes('<svg') && file.size <= WEBLINK_ICON_MAX_SVG_BYTES ) {
|
||||
const dataUrl = await readFileAsDataUrl(file);
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
|
||||
const svgIcon = `data:image/svg+xml;base64,${base64}`;
|
||||
|
||||
if ( isValidWeblinkIcon(svgIcon) ) {
|
||||
return svgIcon;
|
||||
}
|
||||
}
|
||||
|
||||
const icon = await rasterizeToPngDataUrl(await readFileAsDataUrl(file));
|
||||
|
||||
if ( !isValidWeblinkIcon(icon) ) {
|
||||
throw new Error('Please choose a PNG, JPG, GIF, WebP, or SVG image.');
|
||||
}
|
||||
|
||||
return icon;
|
||||
};
|
||||
|
||||
// Opens Puter's built-in file picker and resolves with a validated icon data
|
||||
// URL, or null if the user dismisses the dialog. The picker is parented to the
|
||||
// item's own window (or a lightweight receiver on the Desktop) and always tears
|
||||
// down via on_close, so no promise or DOM node is ever leaked on cancel.
|
||||
export const chooseWeblinkIcon = async (elItem) => 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 = $('<div>')
|
||||
.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.');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user