Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/backend/src/routers/_default.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,11 @@ router.all('*', async function(req, res, next) {
app_title = config.title;

// /action/
if(path.startsWith('/action/') || path.startsWith('/@')){
if(
path.startsWith('/action/') ||
path.startsWith('/@') ||
path.startsWith('/withapp/')
){
path = '/';
}
// /settings
Expand Down
286 changes: 214 additions & 72 deletions src/gui/src/UI/UIDesktop.js
Original file line number Diff line number Diff line change
Expand Up @@ -1676,101 +1676,170 @@ async function UIDesktop(options) {
//--------------------------------------------------------------------------------------
// Trying to view a user's public folder?
// i.e. https://puter.com/@<username>
// Trying to view a public file in a specific app?
// i.e. https://puter.com/withapp/<app-or-uuid>/@<username>
//--------------------------------------------------------------------------------------
const url_paths = window.location.pathname.split('/').filter(element => element);
if (window.url_paths[0]?.startsWith('@')) {
const username = window.url_paths[0].substring(1);
const url_paths = window.location.pathname
.split('/')
.filter(element => element)
.map(uriComponent => {
try {
return decodeURIComponent(uriComponent);
} catch (e) {
// If the URI component was invalid we can treat it literally.
// There are no security implications because an encoded URI
// could encode the literal text of this component anyway.
return uriComponent;
}
});
const publicShareRoute = getPublicShareRouteFromURL();
if (publicShareRoute) {
await handlePublicShareRoute(publicShareRoute);
}

function getPublicShareRouteFromURL() {
if (window.url_paths[0]?.startsWith('@')) {
return {
usernameSegment: window.url_paths[0],
restSegments: window.url_paths.slice(1),
};
}

if (window.url_paths[0]?.toLocaleLowerCase() === 'withapp') {
const specifiedAppIdentifier = window.url_paths[1];
const usernameSegment = window.url_paths[2];
const restSegments = window.url_paths.slice(3);

if (!specifiedAppIdentifier || !usernameSegment?.startsWith('@')) {
return null;
}

for ( const pathComponent of restSegments ) {
try {
console.log('validating path component', pathComponent);
window.valdate_fsentry_name(pathComponent);
} catch (e) {
UIAlert({
message: i18n('error_invalid_path_in_url'),
type: 'error'
});
return null;
}
}

return {
usernameSegment,
specifiedAppIdentifier,
restSegments: window.url_paths.slice(3),
};
}

return null;
}

async function handlePublicShareRoute({
usernameSegment,
restSegments = [],
specifiedAppIdentifier = null,
}) {
const username = usernameSegment.substring(1);
let item_path = '/' + username + '/Public';
if ( window.url_paths.length > 1 ) {
item_path += '/' + window.url_paths.slice(1).join('/');
if (restSegments.length > 0) {
item_path += '/' + restSegments.join('/');
}

// GUARD: avoid invalid user directories
{
if (!username.match(/^[a-z0-9_]+$/i)) {
UIAlert({
message: i18n('error_invalid_username')
});
return;
}
if (!isValidPublicUsername(username)) {
await alertInvalidUsername();
return;
}

let stat;
try {
stat = await puter.fs.stat({path: item_path, consistency: 'eventual'});
} catch ( e ) {
stat = await puter.fs.stat({ path: item_path, consistency: 'eventual' });
} catch (_e) {
window.history.replaceState(null, document.title, '/');
UIAlert({
await UIAlert({
message: i18n('error_user_or_path_not_found'),
type: 'error'
});
return;
}

// TODO: DRY everything here with open_item. Unfortunately we can't
// use open_item here because it's coupled with UI logic;
// it requires a UIItem element and cannot operate on a
// file path on its own.
if ( ! stat.is_dir ) {
if ( stat.associated_app ) {
if (!stat.is_dir) {
if (specifiedAppIdentifier) {
const specifiedResult = await tryLaunchSpecifiedApp(specifiedAppIdentifier, item_path);
if (specifiedResult.success) {
return;
}

let specifiedMessage = 'Unable to launch the requested app.';
if (specifiedResult.reason === 'app_not_found') {
specifiedMessage = 'Requested app could not be found.';
} else if (specifiedResult.reason === 'open_item_failed') {
specifiedMessage = 'Unable to prepare file for the requested app.';
}

await UIAlert({
message: specifiedMessage + ' Opening directory instead.',
type: 'error',
});
item_path = item_path.split('/').slice(0, -1).join('/');
}
else if (stat.associated_app) {
launch_app({ name: stat.associated_app.name });
return;
}

const ext_pref =
window.user_preferences[`default_apps${path.extname(item_path).toLowerCase()}`];

if ( ext_pref ) {
launch_app({
name: ext_pref,
file_path: item_path,
else {
const ext_pref =
window.user_preferences[`default_apps${path.extname(item_path).toLowerCase()}`];

if (ext_pref) {
launch_app({
name: ext_pref,
file_path: item_path,
});
return;
}

let open_item_meta;
try {
open_item_meta = await fetchOpenItemMeta(item_path);
} catch (e) {
console.error('Failed to open public file', e);
await UIAlert({
message: 'Unable to open this file right now.',
type: 'error',
});
return;
}

const suggested_apps = open_item_meta?.suggested_apps ?? await window.suggest_apps_for_fsentry({
path: item_path
});
return;
}


const open_item_meta = await $.ajax({
url: window.api_origin + "/open_item",
type: 'POST',
contentType: "application/json",
data: JSON.stringify({
path: item_path,
}),
headers: {
"Authorization": "Bearer "+window.auth_token
},
statusCode: {
401: function () {
window.logout();
},
},
});
const suggested_apps = open_item_meta?.suggested_apps ?? await window.suggest_apps_for_fsentry({
path: item_path
});
// Note: I'm not adding unzipping logic here. We'll wait until
// we've refactored open_item so that Puter can have a
// properly-reusable open function.
if (suggested_apps.length !== 0) {
launch_app({
name: suggested_apps[0].name,
token: open_item_meta.token,
file_path: item_path,
app_obj: suggested_apps[0],
window_title: path.basename(item_path),
maximized: options.maximized,
file_signature: open_item_meta.signature,
custom_path: window.location.pathname,
});
return;
}

// Note: I'm not adding unzipping logic here. We'll wait until
// we've refactored open_item so that Puter can have a
// properly-reusable open function.
if ( suggested_apps.length !== 0 ) {
launch_app({
name: suggested_apps[0].name,
token: open_item_meta.token,
file_path: item_path,
app_obj: suggested_apps[0],
window_title: path.basename(item_path),
maximized: options.maximized,
file_signature: open_item_meta.signature,
custom_path: window.location.pathname,
await UIAlert({
message: 'Cannot find an app to open this file; opening directory instead.'
});
return;
item_path = item_path.split('/').slice(0, -1).join('/');
}

await UIAlert({
message: 'Cannot find an app to open this file; ' +
'opening directory instead.'
});
item_path = item_path.split('/').slice(0, -1).join('/')
}

UIWindow({
Expand All @@ -1782,6 +1851,79 @@ async function UIDesktop(options) {
});
}

async function tryLaunchSpecifiedApp(appIdentifier, itemPath) {
const appInfo = await resolveAppByIdentifier(appIdentifier);
if (!appInfo) {
return { success: false, reason: 'app_not_found' };
}

let open_item_meta;
try {
open_item_meta = await fetchOpenItemMeta(itemPath);
} catch (e) {
console.error('Unable to prepare file for specified app', e);
return { success: false, reason: 'open_item_failed' };
}

launch_app({
name: appInfo.name,
token: open_item_meta.token,
file_path: itemPath,
app_obj: appInfo,
window_title: path.basename(itemPath),
maximized: options.maximized,
file_signature: open_item_meta.signature,
custom_path: window.location.pathname,
});

return { success: true };
}

async function fetchOpenItemMeta(itemPath) {
return $.ajax({
url: window.api_origin + "/open_item",
type: 'POST',
contentType: "application/json",
data: JSON.stringify({
path: itemPath,
}),
headers: {
"Authorization": "Bearer " + window.auth_token
},
statusCode: {
401: function () {
window.logout();
},
},
});
}

function isValidPublicUsername(username) {
return /^[a-z0-9_]+$/i.test(username);
}

async function alertInvalidUsername() {
await UIAlert({
message: i18n('error_invalid_username'),
});
}

async function resolveAppByIdentifier(identifier) {
if (!identifier) {
return null;
}

try {
return await puter.apps.get(identifier, { icon_size: 64 });
} catch (_err) {
try {
return await puter.apps.get({ uid: identifier, icon_size: 64 });
} catch (_innerErr) {
return null;
}
}
}

//--------------------------------------------------------------------------------------
// Direct download link
// i.e. https://puter.com/?download=<file_url>
Expand Down Expand Up @@ -2507,4 +2649,4 @@ $(document).on('click', '.btn-show-ai', function () {
$('.window[data-app="ai"]').makeWindowVisible();
});

export default UIDesktop;
export default UIDesktop;
8 changes: 7 additions & 1 deletion src/gui/src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3185,4 +3185,10 @@ window.handle_same_name_exists = async ({
}
return false;
}
}
}

// TODO: it would be better to define validation rules for both frontend and
// backend in a common place; maybe we move these to putility?
window.validate_username = username => {
return /^[a-z0-9_]+$/i.test(username);
};
1 change: 1 addition & 0 deletions src/gui/src/i18n/translations/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ const en = {
'set_as_background': 'Set as Desktop Background',

'error_user_or_path_not_found': 'User or path not found.',
'error_invalid_path_in_url': 'The URL does not contain a valid path to a file or directory on Puter.',
'error_invalid_username': 'Invalid username.',
}
};
Expand Down