Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 697fa58b97 Show an item as shared by the person the Shared view is filtered to
An item several people shared showed the strongest grant's issuer in
the Shared by column even when the listing was filtered to one of the
others. A filtered row now shows the person filtered to, which also
keeps the Shared by sort in step with what the column displays.
2026-11-09 14:42:56 -07:00
Nariman Jelveh ba9c0a8971 Keep an item listed after Remove from Shared when a team still shares it
Leaving an item drops the grants made to the user directly. One a team
also holds stays shared, but the desktop took its icon away until the
next refresh and the dashboard removed the row before re-listing it. A
merged row now records that a team holds the item too. The desktop
re-lists the Shared folder so the item shows as the team share it has
become, and the dashboard leaves the row in place for its refresh.
2026-11-09 14:37:05 -07:00
Nariman Jelveh 290e01fe18 Keep a shared item under each person who shared it in the people filter
A merged row carried only the strongest grant's issuer, so filtering the
Shared view to someone whose grant was the weaker one hid an item they
shared, and their chip left it out of its count. A merged row now lists
every issuer. The filter and its counts match any of them, Everyone
counts the item once, and the Shared by tooltip names them all.
2026-11-09 14:35:30 -07:00
Nariman Jelveh 5c2b4da9f0 Show a shared item once, with its strongest access, when it has more than one share
The Shared listing returns one row per grant, so an item shared by two
people, or directly and through a team, appeared twice and took its
actions from whichever grant was listed last. The GUI now merges the
rows for an item and keeps the strongest mode.
2026-11-09 14:30:38 -07:00
34 changed files with 403 additions and 1341 deletions

No files matched your search

+31 -14
View File
@@ -330,11 +330,14 @@ export class AppDriver extends PuterDriver {
}
/**
* Paged listing of the caller's own apps. Apps owned by anyone else are
* reachable only by name or uid, through `read` and `/query/app`.
* Paged app listing. Unlike `/query/app`, which answers only for apps named
* by the caller and so must not confirm ones it may not see, this is the
* listing surface a launcher reads: `protected` apps are filtered by the
* permission check below, and everything else is listable by design. Narrow
* it with `predicate: ['user-can-edit']` to the caller's own.
*/
async select(args = {}) {
const { params = {} } = args;
const { predicate, params = {} } = args;
const actor = requireContextActor();
this.#requireUserOrAppActor(actor);
@@ -350,12 +353,16 @@ export class AppDriver extends PuterDriver {
const includeTotal = args.includeTotal === true;
const paginated = hasCursor || offset !== undefined || includeTotal;
// Always the caller's own. `predicate: ['user-can-edit']` asks for the
// same thing and is accepted for older clients.
const ownerUserId = actor.user.id;
const filters = {};
// predicate: ['user-can-edit'] → scope to owner
const ownerScoped =
Array.isArray(predicate) && predicate[0] === 'user-can-edit';
if (ownerScoped) {
filters.ownerUserId = actor.user.id;
}
let apps = await this.appStore.list({
ownerUserId,
...filters,
limit: paginated ? limit + 1 : limit,
offset,
afterId: payload?.id !== undefined ? Number(payload.id) : undefined,
@@ -364,18 +371,23 @@ export class AppDriver extends PuterDriver {
let cursor;
if (paginated && apps.length > limit) {
apps = apps.slice(0, limit);
// The cursor tracks the last fetched row, not the last visible
// one, so rows hidden by the permission filter below aren't
// re-scanned on the next page.
cursor = sealCursor(
{ id: Number(apps[apps.length - 1].id) },
this.config.jwt_secret_v2,
);
}
// Stats and the shared views are each one batched lookup per page.
// Protected apps are filtered by one batched grant check; stats and
// the shared views are each one batched lookup across the page.
const visible = await this.services.app.filterReadable(apps, actor);
const [statsByUid, views] = await Promise.all([
this.appStore.getAppsStats(apps.map((a) => a.uid)),
this.services.app.views(apps, actor),
this.appStore.getAppsStats(visible.map((a) => a.uid)),
this.services.app.views(visible, actor),
]);
const items = apps.map((app, i) =>
const items = visible.map((app, i) =>
toAppReadView(app, views[i], {
viewer: actor.user,
stats: statsByUid.get(app.uid),
@@ -384,9 +396,14 @@ export class AppDriver extends PuterDriver {
);
if (!paginated) return items;
const total = includeTotal
? await this.appStore.count({ ownerUserId })
: undefined;
let total;
if (includeTotal) {
total = ownerScoped
? await this.appStore.count({ ownerUserId: actor.user.id })
: await this.appStore.count({
visibleToUserId: actor.user?.id ?? null,
});
}
return {
items,
+41 -32
View File
@@ -509,7 +509,7 @@ describe('AppDriver.read', () => {
// ── select ──────────────────────────────────────────────────────────
describe('AppDriver.select', () => {
it("returns only the caller's apps when no predicate is sent", async () => {
it('returns visible apps including those owned by other users', async () => {
const a = await makeUser();
const b = await makeUser();
const aName = uniqueName('a');
@@ -538,7 +538,7 @@ describe('AppDriver.select', () => {
)) as Array<Record<string, unknown>>;
const names = result.map((r) => r.name);
expect(names).toContain(aName);
expect(names).not.toContain(bName);
expect(names).toContain(bName);
});
it('predicate `user-can-edit` filters to actor-owned apps only', async () => {
@@ -1670,10 +1670,11 @@ describe('AppDriver.select pagination', () => {
expect(page.total).toBe(names.length);
});
it("pages through only the caller's apps, protected ones included", async () => {
it("hides other users' protected apps from paginated catalog listings", async () => {
const a = await makeApps(3);
const [visible1, hidden, visible2] = a.names;
const created = await withActor(a.actor, () =>
driver.read({ id: { name: a.names[1] } }),
driver.read({ id: { name: hidden } }),
);
const row = await server.stores.app.getByUid(
(created as Record<string, unknown>).uid as string,
@@ -1684,29 +1685,20 @@ describe('AppDriver.select pagination', () => {
);
await server.stores.app.invalidateByUid(row!.uid as string);
const listAll = async (actor: Actor) => {
const seen: string[] = [];
let total: number | undefined;
let cursor: string | null | undefined = null;
do {
const page = (await withActor(actor, () =>
driver.select({ limit: 2, cursor, includeTotal: true }),
)) as {
items: Array<{ name: string }>;
cursor?: string;
total?: number;
};
seen.push(...page.items.map((r) => r.name));
total ??= page.total;
cursor = page.cursor;
} while (cursor);
return { seen, total };
};
expect(await listAll(a.actor)).toEqual({ seen: a.names, total: 3 });
const b = await makeUser();
expect(await listAll(b.actor)).toEqual({ seen: [], total: 0 });
const seen: string[] = [];
let cursor: string | null | undefined = null;
do {
const page = (await withActor(b.actor, () =>
driver.select({ limit: 50, cursor }),
)) as { items: Array<{ name: string }>; cursor?: string };
seen.push(...page.items.map((r) => r.name));
cursor = page.cursor;
} while (cursor);
expect(seen).toContain(visible1);
expect(seen).toContain(visible2);
expect(seen).not.toContain(hidden);
});
});
@@ -2525,6 +2517,22 @@ describe('AppDriver.select query-count regression', () => {
// -- select / read parity --
describe('AppDriver.select / read parity', () => {
const findViaBroadSelect = async (
actor: Actor,
uid: string,
): Promise<Record<string, unknown> | undefined> => {
let cursor: string | null | undefined = null;
do {
const page = (await withActor(actor, () =>
driver.select({ limit: 50, cursor }),
)) as { items: Array<Record<string, unknown>>; cursor?: string };
const found = page.items.find((r) => r.uid === uid);
if (found) return found;
cursor = page.cursor;
} while (cursor);
return undefined;
};
it('every select item (minus stats) deep-equals the corresponding read', async () => {
const { actor } = await makeUser();
const names: string[] = [];
@@ -2632,7 +2640,7 @@ describe('AppDriver.select / read parity', () => {
).toBe(false);
});
it('dangling hosted app: owner keeps index_url in select, others do not in read', async () => {
it('dangling hosted app via select: owner keeps index_url, others do not', async () => {
const owner = await makeUser();
const other = await makeUser();
const sub = uniqueName('qcdangsel');
@@ -2664,12 +2672,13 @@ describe('AppDriver.select / read parity', () => {
?.reason,
).toBe('hosted_backing_unavailable');
const otherItem = (await withActor(other.actor, () =>
driver.read({ uid: created.uid }),
)) as Record<string, unknown>;
expect(otherItem.index_url).toBeUndefined();
const otherItem = await findViaBroadSelect(
other.actor,
created.uid as string,
);
expect(otherItem?.index_url).toBeUndefined();
expect(
(otherItem.privateAccess as { hasAccess?: boolean } | undefined)
(otherItem?.privateAccess as { hasAccess?: boolean } | undefined)
?.hasAccess,
).toBe(false);
});
@@ -20,7 +20,6 @@
import { createHmac } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { describe, expect, it } from 'vitest';
import { isTokenForAnotherUser } from '../../../gui/src/helpers/tokenUser.js';
import { TokenService } from './TokenService.js';
const V2_SECRET = 'test-v2-secret';
@@ -385,21 +384,3 @@ describe('TokenService payload key handling', () => {
},
);
});
describe('session token read by the desktop', () => {
// The desktop reads the user out of a token another tab stored, to tell
// an account switch from a renewed token for the same account.
it('names its user in the form the desktop decodes', () => {
const svc = createTokenService();
const userUid = '5d4adce0-a381-4982-9c02-6e2540026238';
const otherUid = 'a1b2c3d4-0000-4000-8000-00000000beef';
const token = svc.sign('auth', {
type: 'gui',
version: '2',
user_uid: userUid,
});
expect(isTokenForAnotherUser(token, { uuid: userUid })).toBe(false);
expect(isTokenForAnotherUser(token, { uuid: otherUid })).toBe(true);
});
});
+12 -1
View File
@@ -686,7 +686,12 @@ export class AppStore extends PuterStore {
return apps;
}
/** Count apps matching the same equality filters as `list`. */
/**
* Count apps matching the same equality filters as `list`.
* `visibleToUserId` counts the public catalog for one caller —
* non-protected apps plus their own; apps visible only via explicit
* permission grants are not included.
*/
async count(filters = {}) {
const where = [];
const params = [];
@@ -699,6 +704,12 @@ export class AppStore extends PuterStore {
where.push('`app_owner` = ?');
params.push(filters.appOwner);
}
if (filters.visibleToUserId !== undefined) {
where.push(
'(`protected` IS NULL OR `protected` = false OR `owner_user_id` = ?)',
);
params.push(filters.visibleToUserId);
}
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const rows = await this.clients.db.read(
+19 -2
View File
@@ -1292,14 +1292,31 @@ describe('AppStore CRUD and cache invalidation', () => {
).toBe('Bumped');
});
it('counts apps by owner and creator', async () => {
it('counts apps by owner, creator and per-caller visibility', async () => {
const countOwner = 4600;
const viewer = 4601;
const open = await createApp({}, { ownerUserId: countOwner });
await createApp({}, { ownerUserId: countOwner, appOwner: open.id });
await createApp({}, { ownerUserId: countOwner });
const hidden = await createApp({}, { ownerUserId: countOwner });
await db.write('UPDATE `apps` SET `protected` = 1 WHERE `id` = ?', [
hidden.id,
]);
expect(await appStore.count({ ownerUserId: countOwner })).toBe(3);
expect(await appStore.count({ appOwner: open.id })).toBe(1);
// The protected app is invisible to a stranger but visible to its
// owner.
const strangerView = await appStore.count({
ownerUserId: countOwner,
visibleToUserId: viewer,
});
expect(strangerView).toBe(2);
const ownerView = await appStore.count({
ownerUserId: countOwner,
visibleToUserId: countOwner,
});
expect(ownerView).toBe(3);
});
// -- filetype association batch + recent opens ----------------------
+1 -1
View File
@@ -1339,7 +1339,7 @@ async function saveEditedApp (mode) {
// Update title
$(`.app-title[data-uid="${uid}"]`).html(html_encode(app.title));
// Update app link
$(`.app-url[data-uid="${uid}"]`).html(html_encode(applink(app)));
$(`.app-url[data-uid="${uid}"]`).html(applink(app));
$(`.app-url[data-uid="${uid}"]`).attr('href', applink(app));
// Update icons
$(`.app-icon[data-uid="${uid}"]`).attr('src', html_encode(app.icon ? app.icon : './img/app.svg'));
+16 -15
View File
@@ -38,27 +38,22 @@ window.user = null;
}
})();
// domain and port are built into links and deploy URLs, so anything other
// than a bare hostname and a numeric port is ignored
const domainParam = window.url_params.get('puter.domain');
if ( domainParam && /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i.test(domainParam) ) {
window.domain = domainParam;
// domain and APIOrigin
if ( window.url_params.has('puter.domain') ) {
window.domain = window.url_params.get('puter.domain');
}
const portParam = window.url_params.get('puter.port');
window.port = portParam && /^\d{1,5}$/.test(portParam) ? portParam : '';
// static hosting domain
window.static_hosting_domain = 'puter.site';
if ( window.domain === 'puter.localhost' ) {
window.static_hosting_domain = 'site.puter.localhost';
} else if ( window.domain && window.domain !== 'puter.com' ) {
window.static_hosting_domain = `site.${window.domain}`;
}
if ( window.domain === 'puter.localhost' ) {
window.static_hosting_domain = 'site.puter.localhost';
} else if ( window.domain && window.domain !== 'puter.com' ) {
window.static_hosting_domain = `site.${window.domain}`;
}
// add port to static_hosting_domain if provided
if ( window.port ) {
window.static_hosting_domain = `${window.static_hosting_domain}:${window.port}`;
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
window.static_hosting_domain = `${window.static_hosting_domain }:${ html_encode(window.url_params.get('puter.port'))}`;
}
// protocol
@@ -68,6 +63,12 @@ if ( window.url_params.has('puter.protocol') && window.url_params.get('puter.pro
window.protocol = 'http';
}
// port
window.port = '';
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
window.port = html_encode(window.url_params.get('puter.port'));
}
// source_path
if ( window.url_params.has('source_path') ) {
window.source_path = window.url_params.get('source_path');
+10 -32
View File
@@ -34,7 +34,7 @@ import UIWindowProgress from './UIWindowProgress.js';
import UITaskbar from './UITaskbar.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath, retargetNavHistory, retargetPaths, underPath } from '../helpers/pathElements.js';
import { atPath, retargetPaths, underPath } from '../helpers/pathElements.js';
import changeLanguage from '../i18n/i18nChangeLanguage.js';
import UIWindowTaskManager from './UIWindowTaskManager.js';
import truncate_filename from '../helpers/truncateFilename.js';
@@ -233,12 +233,7 @@ async function UIDesktop (options) {
// empty trash windows if needed
if ( msg.is_empty )
{
// only the rows: the container also holds its empty, error and loading states
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).each(function () {
$(this).find('.item-container .item').removeItems();
window.update_explorer_footer_item_count(this);
window.update_explorer_footer_selected_items_count(this);
});
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.item-container').empty();
}
});
@@ -421,17 +416,14 @@ async function UIDesktop (options) {
return;
}
// remove all UIItems at the removed path
// hide all UIItems with matching uids
atPath('.item', item.path).fadeOut(150, function () {
const $parent_window = $(this).closest('.window');
$(this).removeItems();
window.update_explorer_footer_item_count($parent_window);
window.update_explorer_footer_selected_items_count($parent_window);
// close all windows with matching uids
// $('.window-' + item.uid).close();
// close all windows that belong to a descendant of this item
// todo this has to be case-insensitive
underPath('.window', item.path).close();
});
// close the windows showing the removed folder or anything inside it,
// whether or not its own row is displayed anywhere
// todo this has to be case-insensitive
atPath('.window[data-app="explorer"]', item.path).add(underPath('.window', item.path)).close();
});
window.socket.on('item.updated', async (item) => {
@@ -463,14 +455,6 @@ async function UIDesktop (options) {
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
// An overwrite arrives as an update, with a new size and date
if ( item.size !== undefined && item.modified !== undefined ) {
const $updated_items = $(`.item[data-uid='${item.uid}']`);
$updated_items.attr({ 'data-size': item.size ?? '', 'data-modified': item.modified ?? '' });
$updated_items.find('.item-attr--size > span').text(item.size ? window.byte_format(item.size) : '-');
$updated_items.find('.item-attr--modified > span').text(item.modified === 0 ? '-' : timeago.format(item.modified * 1000));
}
// Set new data-path
const new_path = item.path;
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
@@ -539,7 +523,6 @@ async function UIDesktop (options) {
underPath('.window', resp.old_path).add(atPath('.window', resp.old_path)).each(function () {
window.update_window_path(this, fsentry.path + $(this).attr('data-path').slice(resp.old_path.length));
});
retargetNavHistory(resp.old_path, fsentry.path);
}
if ( dest_path === window.trash_path ) {
@@ -563,18 +546,13 @@ async function UIDesktop (options) {
// Trashing renames the entry to its uid; the real name is in metadata.
fsentry.name = metadata.original_name ?? fsentry.name;
const moved_item_icon = await item_icon(fsentry);
const $dest_containers = atPath('.item-container', dest_path);
// the tab that made this move renders the row itself, in either order
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
// create new item on matching containers
UIItem({
appendTo: $dest_containers,
appendTo: atPath('.item-container', dest_path),
immutable: fsentry.immutable || fsentry.writable === false,
uid: fsentry.uid,
path: fsentry.path,
icon: moved_item_icon,
icon: await item_icon(fsentry),
name: fsentry.name,
is_dir: fsentry.is_dir,
size: fsentry.size,
+16 -38
View File
@@ -396,7 +396,7 @@ async function UIItem (options) {
// do not highlight if the target is the same as the item being moved
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') &&
// do not highlight if item is being moved to where it already is
path.dirname($(el_item).attr('data-path')) !== $(window.mouseover_item_container).attr('data-path') ) {
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') ) {
// highlight item container
$(window.mouseover_item_container).addClass('item-container-active');
@@ -416,8 +416,7 @@ async function UIItem (options) {
},
stop: function (event, ui) {
// Allow rearranging only if item is on desktop, not trash container, auto arrange is disabled and item is not dropped into another item
// the desktop itself, not a window showing the Desktop folder
if ( $(el_item).closest('.item-container').hasClass('desktop') &&
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path &&
!window.is_auto_arrange_enabled && $(el_item).attr('data-path') !== window.trash_path && !ui.helper.data('dropped') &&
// Item must be dropped on the Desktop and not on the taskbar
window.mouseover_window === undefined && ui.position.top <= window.desktop_height - window.taskbar_height - 15 ) {
@@ -435,9 +434,7 @@ async function UIItem (options) {
// re-enable all droppable UIItems that are not a dir
$('.item[data-is_dir=\'0\']:not(.item-selected)').droppable('enable');
// remove active item-container border highlights
$('.item-container').removeClass('item-container-active item-container-transparent-border');
// a drop that bailed out early never re-enables the containers an item's hover disabled
$('.item-container.ui-droppable').droppable('enable');
$('.item-container').removeClass('item-container-active');
// reset longer hover timeout and last window dragged over
clearTimeout(longer_hover_timeout);
last_window_dragged_over = null;
@@ -551,14 +548,11 @@ async function UIItem (options) {
}
// Otherwise, move items
else if ( options.is_dir ) {
// a selected folder can be the drop target of its own selection; it stays where it is
const items_to_drop = items_to_move.filter(item => $(item).attr('data-uid') !== $(el_item).attr('data-uid'));
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) {
// the items leaving the desktop lose their saved positions, not the folder they land in
items_to_drop.forEach(item => delete window.desktop_item_positions[$(item).attr('data-uid')]);
delete window.desktop_item_positions[$(el_item).attr('data-uid')];
window.save_desktop_item_positions();
}
window.move_items(items_to_drop, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
window.move_items(items_to_move, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
}
}
@@ -569,9 +563,8 @@ async function UIItem (options) {
},
over: function (event, ui) {
// Check hovering over an item that is VISIBILE
// A desktop item has no window, so it only counts while the pointer is over the desktop itself
const $event_parent_win = $(event.target).closest('.window');
if ( $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
if ( $event_parent_win.length > 0 && $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
{
return;
}
@@ -729,7 +722,7 @@ async function UIItem (options) {
}
});
$(document).on(`click.item-${item_id}`, function (e) {
$(document).on('click', function (e) {
if ( !$(e.target).hasClass('item') && !$(e.target).hasClass('item-name') && !$(e.target).hasClass('item-icon') ) {
skip_a_rename_click = true;
}
@@ -738,10 +731,6 @@ async function UIItem (options) {
skip_a_rename_click = true;
}
});
// a document handler outlives the row unless it is released with it
$(el_item).on('remove', function () {
$(document).off(`click.item-${item_id}`);
});
// --------------------------------------------------------
// Rename
@@ -749,7 +738,6 @@ async function UIItem (options) {
function rename () {
if ( rename_cancelled ) {
rename_cancelled = false;
$(el_item_name_editor).removeClass('item-name-editor-active');
return;
}
@@ -769,12 +757,10 @@ async function UIItem (options) {
UIAlert('The name ".." is not allowed, because it is a reserved name. Please choose another name.');
}
$(el_item_name).html(html_encode(truncate_filename($(el_item).attr('data-name'))));
$(el_item_name).html(html_encode(truncate_filename(options.name)));
$(el_item_name).show();
$(el_item_name_editor).val($(el_item).attr('data-name'));
$(el_item_name_editor).hide();
// the Delete shortcut skips an item whose editor is marked active
$(el_item_name_editor).removeClass('item-name-editor-active');
return;
}
// deactivate item name editable
@@ -820,9 +806,8 @@ async function UIItem (options) {
e.stopPropagation();
e.preventDefault();
rename_cancelled = true;
$(el_item_name_editor).removeClass('item-name-editor-active');
$(el_item_name_editor).hide();
$(el_item_name_editor).val($(el_item).attr('data-name'));
$(el_item_name_editor).val(options.name);
$(el_item_name).show();
}
});
@@ -881,11 +866,6 @@ async function UIItem (options) {
return;
}
// The menu is built after lookups that take time, so it opens where the pointer was when it was asked for.
const menu_position = (isMobile.phone || isMobile.tablet)
? { left: window.last_touch_x, top: window.last_touch_y }
: { left: window.mouseX, top: window.mouseY };
// if editing item name, preserve native context menu
if ( event.target === el_item_name_editor )
{
@@ -1157,7 +1137,7 @@ async function UIItem (options) {
// try to find suitable apps
const suitable_apps = await window.suggest_apps_for_fsentry({
uid: options.uid,
path: $(el_item).attr('data-path'),
path: options.path,
});
if ( suitable_apps && suitable_apps.length > 0 ) {
options.suggested_apps = suitable_apps;
@@ -1295,7 +1275,7 @@ async function UIItem (options) {
file_path: $(el_item).attr('data-path'),
file_uid: $(el_item).attr('data-uid'),
params: {
source_path: $(el_item).attr('data-path'),
source_path: options.path,
},
});
},
@@ -1342,7 +1322,7 @@ async function UIItem (options) {
}
else
{
window.trigger_download([$(el_item).attr('data-path')]);
window.trigger_download([options.path]);
}
},
});
@@ -1457,13 +1437,12 @@ async function UIItem (options) {
// -------------------------------------------
// Cut
// -------------------------------------------
// Trashed items leave the Trash through Restore; moved any other way, they'd keep their uid as their name.
if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
if ( $(el_item).attr('data-immutable') === '0' ) {
menu_items.push({
html: i18n('cut'),
onClick: function () {
window.clipboard_op = 'move';
window.clipboard = [$(el_item).attr('data-path')];
window.clipboard = [options.path];
},
});
}
@@ -1475,7 +1454,7 @@ async function UIItem (options) {
html: i18n('copy'),
onClick: function () {
window.clipboard_op = 'copy';
window.clipboard = [{ path: $(el_item).attr('data-path') }];
window.clipboard = [{ path: options.path }];
},
});
}
@@ -1523,7 +1502,7 @@ async function UIItem (options) {
base_dir,
options.appendTo,
options.shortcut_to === '' ? options.uid : options.shortcut_to,
options.shortcut_to_path === '' ? $(el_item).attr('data-path') : options.shortcut_to_path,
options.shortcut_to_path === '' ? options.path : options.shortcut_to_path,
);
},
});
@@ -1652,7 +1631,6 @@ async function UIItem (options) {
// Create ContextMenu
UIContextMenu({
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
position: menu_position,
items: menu_items,
});
+125 -142
View File
@@ -26,7 +26,6 @@ import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath } from '../helpers/pathElements.js';
import { resolve_typed_path } from '../helpers/expandHomePath.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
import launch_app from '../helpers/launchApp.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
@@ -348,18 +347,7 @@ async function UIWindow (options) {
if ( ! items.some(item => item.path === window.home_path) ) {
items.unshift({ path: window.home_path, name: i18n('home') });
}
// A saved order stores the label it was rendered with; system folders follow the current language instead.
const system_folder_labels = {
[window.home_path]: i18n('home'),
[window.docs_path]: i18n('documents'),
[window.public_path]: i18n('public'),
[window.pictures_path]: i18n('pictures'),
[window.desktop_path]: i18n('desktop'),
[window.videos_path]: i18n('videos'),
[window.shared_path]: i18n('shared'),
};
for ( let item of items ) {
const label = system_folder_labels[item.path] ?? item.name;
let icon;
if ( item.path === window.home_path )
{
@@ -393,7 +381,7 @@ async function UIWindow (options) {
{
icon = window.icons['sidebar-folder.svg'];
}
h += `<div title="${html_encode(label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(label)}</div>`;
h += `<div title="${html_encode(item.label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(item.name)}</div>`;
}
}
h += '</div>';
@@ -418,7 +406,7 @@ async function UIWindow (options) {
// Path editor
h += `<input class="window-navbar-path-input" data-path="${html_encode(options.path)}" value="${html_encode(options.path)}" spellcheck="false"/>`;
// Layout settings
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false" title="${i18n('window_click_to_change_layout')}">`;
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false">`;
h += '</div>';
}
@@ -478,7 +466,7 @@ async function UIWindow (options) {
// Loading spinner
h += '<div class="explorer-loading-spinner">';
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
h += `<p class="explorer-loading-spinner-msg">${i18n('loading')}...</p>`;
h += '</div>';
}
@@ -1126,7 +1114,7 @@ async function UIWindow (options) {
// build item for context menu
items.push({
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
val: index,
onClick: async function (e) {
let history_index = e.value;
@@ -1199,7 +1187,7 @@ async function UIWindow (options) {
// build item for context menu
items.push({
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
html: `<span>${history_item === window.home_path ? 'Home' : html_encode(path.basename(history_item))}</span>`,
val: index,
onClick: async function (e) {
let history_index = e.value;
@@ -1345,8 +1333,6 @@ async function UIWindow (options) {
$(el_window).on('remove', function (e) {
// if on_close callback is set, call it
options.on_close?.();
delete window.window_nav_history[win_id];
delete window.window_nav_history_current_position[win_id];
});
// --------------------------------------------------------
@@ -1440,18 +1426,18 @@ async function UIWindow (options) {
.on('beforedrag', evt => {
})
.on('start', ({ store, event }) => {
// Get the scroll position of the window body
const scrollLeft = $(el_window_body).scrollLeft();
const scrollTop = $(el_window_body).scrollTop();
// Get the window body's bounding rect relative to the viewport
const windowBodyRect = el_window_body.getBoundingClientRect();
// Calculate position relative to the window body (accounting for scroll)
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
if ( !event.ctrlKey && !event.metaKey ) {
// Get the scroll position of the window body
const scrollLeft = $(el_window_body).scrollLeft();
const scrollTop = $(el_window_body).scrollTop();
// Get the window body's bounding rect relative to the viewport
const windowBodyRect = el_window_body.getBoundingClientRect();
// Calculate position relative to the window body (accounting for scroll)
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
for ( const el of store.stored ) {
el.classList.remove('item-selected');
}
@@ -1526,7 +1512,7 @@ async function UIWindow (options) {
window.update_explorer_footer_selected_items_count(el_window);
// If this is openFileDialog, enable/disable the Open button accordingly
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
{
$(el_openfiledialog_open_btn).removeClass('disabled');
}
@@ -1537,7 +1523,7 @@ async function UIWindow (options) {
})
.on('stop', ({ store, event }) => {
// If this is openFileDialog, enable/disable the Open button accordingly
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
{
$(el_openfiledialog_open_btn).removeClass('disabled');
}
@@ -1546,20 +1532,6 @@ async function UIWindow (options) {
$(el_openfiledialog_open_btn).addClass('disabled');
}
});
$(document).on(`mouseup.window-selection-${win_id}`, function () {
if ( selection_area ) {
$(selection_area).hide();
$(selection_area).remove();
selection_area = null;
}
});
// viselect and the handler above listen on document, so they outlive the window unless released
$(el_window).on('remove', function () {
$(document).off(`mouseup.window-selection-${win_id}`);
selection.destroy();
});
}
// --------------------------------------------------------
@@ -2707,8 +2679,37 @@ async function UIWindow (options) {
{
html: i18n('empty_trash'),
disabled: false,
onClick: function () {
window.empty_trash();
onClick: async function () {
// TODO: Merge this with window.empty_trash()
const alert_resp = await UIAlert({
message: i18n('empty_trash_confirmation'),
buttons: [
{
label: i18n('yes'),
value: 'yes',
type: 'primary',
},
{
label: i18n('no'),
value: 'no',
},
],
});
if ( alert_resp === 'no' )
{
return;
}
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.item[data-path^="${html_encode(window.trash_path)}/"]`).each(function () {
window.delete_item(this);
});
// update other clients
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: true });
}
// use the 'empty trash' icon
window.update_trash_icons(true);
},
},
],
@@ -2844,6 +2845,14 @@ async function UIWindow (options) {
});
}
$(document).on('mouseup', function (e) {
if ( selection_area ) {
$(selection_area).hide();
$(selection_area).remove();
selection_area = null;
}
});
//set styles
$(el_window_body).css(options.body_css);
@@ -3063,21 +3072,7 @@ $(document).on('blur', '.window-navbar-path-input', function (e) {
$(document).on('keyup', '.window-navbar-path-input', function (e) {
if ( e.key === 'Enter' || e.keyCode === 13 ) {
const $el_window = $(e.target).closest('.window');
const win_id = $el_window.attr('data-id');
const current_path = $el_window.attr('data-path');
// Other code matches containers by exact path, so navigate to the canonical form.
const target_path = resolve_typed_path($(e.target).val(), {
current_path,
home_path: window.home_path,
shared_path: window.shared_path,
});
if ( target_path !== current_path && window.window_nav_history[win_id] ) {
window.window_nav_history[win_id] = window.window_nav_history[win_id].slice(0, window.window_nav_history_current_position[win_id] + 1);
window.window_nav_history[win_id].push(target_path);
window.window_nav_history_current_position[win_id] = window.window_nav_history[win_id].length - 1;
}
window.update_window_path($el_window, target_path);
window.update_window_path($(e.target).closest('.window'), $(e.target).val());
$(e.target).hide();
$(e.target).siblings('.window-navbar-path').show().select();
}
@@ -3341,14 +3336,6 @@ window.navbar_path = (abs_path) => {
return str;
};
// The root and the Shared view have no basename to show in the Back/Forward menus.
const nav_history_label = (history_path) => {
if ( history_path === window.home_path ) return i18n('home');
if ( history_path === '/' ) return window.root_dirname;
if ( history_path === window.shared_path ) return i18n('shared');
return path.basename(history_path);
};
window.update_window_path = async function (el_window, target_path) {
const win_id = $(el_window).attr('data-id');
const el_window_navbar_forward_btn = $(el_window).find('.window-navbar-btn-forward');
@@ -3406,8 +3393,6 @@ window.update_window_path = async function (el_window, target_path) {
// empty body to be filled with the results of /readdir
$(el_window_body).find('.item').removeItems();
// nothing is selected in the folder being opened
$(el_window).find('.openfiledialog-open-btn').addClass('disabled');
// add the 'Detail View' table header
if ( $(el_window).find('.explore-table-headers').length === 0 )
@@ -3450,8 +3435,6 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).attr('data-path', target_path);
$(el_window).attr('data-name', path.basename(target_path));
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// /stat
if ( target_path === window.shared_path ) {
@@ -3462,72 +3445,72 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).find('.window-head-icon').attr('src', window.icons['shared.svg']);
}
else if ( target_path !== '/' ) {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== target_path ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
$(el_window_item_container).attr('data-uid', fsentry.id);
// title - use i18n for system directories
if ( target_path === window.home_path )
{
$(el_window).find('.window-head-title').text(i18n('home'));
}
else if ( target_path === window.desktop_path )
{
$(el_window).find('.window-head-title').text(i18n('desktop'));
}
else if ( target_path === window.docs_path || target_path === window.documents_path )
{
$(el_window).find('.window-head-title').text(i18n('documents'));
}
else if ( target_path === window.pictures_path )
{
$(el_window).find('.window-head-title').text(i18n('pictures'));
}
else if ( target_path === window.videos_path )
{
$(el_window).find('.window-head-title').text(i18n('videos'));
}
else if ( target_path === window.public_path )
{
$(el_window).find('.window-head-title').text(i18n('public'));
}
else if ( target_path === window.trash_path )
{
$(el_window).find('.window-head-title').text(i18n('trash'));
}
else
{
$(el_window).find('.window-head-title').text(fsentry.name);
}
// data-name
$(el_window).attr('data-name', fsentry.name);
// data-path
$(el_window).attr('data-path', target_path);
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
window.update_window_layout(el_window, fsentry.layout);
// update explore header if in details view
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
}).catch(() => {
if ( $(el_window).attr('data-path') !== target_path ) {
return;
}
// The folder couldn't be read, so the window must stop identifying as the previous one.
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).attr('data-uid', 'null');
$(el_window).find('.window-head-title').text(path.basename(target_path));
});
try {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== target_path ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
$(el_window).addClass(`window-${ fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
$(el_window_item_container).attr('data-uid', fsentry.id);
// title - use i18n for system directories
if ( target_path === window.home_path )
{
$(el_window).find('.window-head-title').text(i18n('home'));
}
else if ( target_path === window.desktop_path )
{
$(el_window).find('.window-head-title').text(i18n('desktop'));
}
else if ( target_path === window.docs_path || target_path === window.documents_path )
{
$(el_window).find('.window-head-title').text(i18n('documents'));
}
else if ( target_path === window.pictures_path )
{
$(el_window).find('.window-head-title').text(i18n('pictures'));
}
else if ( target_path === window.videos_path )
{
$(el_window).find('.window-head-title').text(i18n('videos'));
}
else if ( target_path === window.public_path )
{
$(el_window).find('.window-head-title').text(i18n('public'));
}
else if ( target_path === window.trash_path )
{
$(el_window).find('.window-head-title').text(i18n('trash'));
}
else
{
$(el_window).find('.window-head-title').text(fsentry.name);
}
// data-name
$(el_window).attr('data-name', fsentry.name);
// data-path
$(el_window).attr('data-path', target_path);
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
window.update_window_layout(el_window, fsentry.layout);
// update explore header if in details view
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
});
} catch ( err ) {
UIAlert(err.responseText);
// todo optim: this is dumb because updating the window should only happen if this /readdir request is successful,
// in that case there is no need for using update_window_path on error!!
window.update_window_path(el_window, old_path);
}
}
// path is '/' (global root)
else {
@@ -3883,7 +3866,7 @@ window.scale_window = (el_window) => {
window.update_explorer_footer_item_count = function (el_window) {
//update dir count in explorer footer
let item_count = $(el_window).find('.item:not(.item-hidden)').length;
let item_count = $(el_window).find('.item').length;
$(el_window).find('.explorer-footer .explorer-footer-item-count').html(`${item_count } ${i18n('item')}${ item_count == 0 || item_count > 1 ? `${i18n('plural_suffix')}` : ''}`);
};
+2 -29
View File
@@ -500,9 +500,8 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, sel
.explore-table-headers {
display: none;
/* the negative margin below shifts it left by the body's padding */
width: calc(100% + 10px);
min-width: 805px;
width: 100%;
min-width: 795px;
height: 25px;
border-bottom: 1px solid rgb(226, 226, 226);
background-color: #fff;
@@ -535,7 +534,6 @@ span.header-sort-icon img {
margin-left: 15px;
color: #555c61;
display: inline-block;
cursor: pointer;
}
.explore-table-headers-th-active {
@@ -844,14 +842,6 @@ span.header-sort-icon img {
color: white;
}
.device-desktop .item-container-details .item-selected.item-blurred {
background-color: #dbdada;
}
.device-desktop .item-container-details .item-selected.item-blurred .item-attr {
color: rgb(73, 73, 73);
}
.item-container-details .item-attr--modified {
left: 350px;
}
@@ -1209,22 +1199,6 @@ span.header-sort-icon img {
cursor: default;
}
/* A path too long for the bar is cut at the root end, so the current folder stays visible. */
.window-navbar-path {
display: flex;
justify-content: flex-end;
align-items: baseline;
min-height: 37px;
}
.window-navbar-path > * {
flex: none;
}
.window-navbar-path > :last-child {
margin-right: auto;
}
.device-phone .window-navbar-path {
display: none;
}
@@ -1234,7 +1208,6 @@ span.header-sort-icon img {
height: 30px;
margin-left: 10px;
margin-top: 3px;
cursor: pointer;
}
.device-phone .window-navbar-layout-settings {
+74 -117
View File
@@ -23,7 +23,7 @@ import { archiveEntryPath } from './helpers/archiveEntryPath.js';
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
import globToRegExp from './helpers/globToRegExp.js';
import item_icon from './helpers/itemIcon.js';
import { atPath, retargetNavHistory, retargetPaths, underPath } from './helpers/pathElements.js';
import { atPath, retargetPaths, underPath } from './helpers/pathElements.js';
import { is_owned_by_me, trash_path_for } from './helpers/pathOwner.js';
import { invalidate_shared_roots } from './helpers/sharedAccess.js';
import truncate_filename from './helpers/truncateFilename.js';
@@ -32,13 +32,11 @@ import update_username_in_gui from './helpers/updateUsernameInGui.js';
import { notifyItemWatchers } from './helpers/watchItems.js';
import { select_uploaded_items } from './helpers/uploadSelection.js';
import { templateMenuItem } from './helpers/templateMenuItem.js';
import { isTokenForAnotherUser } from './helpers/tokenUser.js';
import { uuidv4 } from './helpers/uuidv4.js';
import mime from './lib/mime.js';
import path from './lib/path.js';
import UIAlert from './UI/UIAlert.js';
import UIItem from './UI/UIItem.js';
import { compareEntries, compareNames } from './UI/Dashboard/sortEntries.js';
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
import UIWindowProgress from './UI/UIWindowProgress.js';
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
@@ -627,6 +625,10 @@ window.refresh_user_data = async (auth_token) => {
window.update_auth_data = async (auth_token, user) => {
window.auth_token = auth_token;
// Write the v2 key going forward and clear any lingering v1 key so
// a single localStorage source-of-truth is used.
localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token);
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
// Set http-only session cookie when user is changing.
// This ensures user-protected endpoints, which only refer to the http-only cookie,
@@ -652,13 +654,6 @@ window.update_auth_data = async (auth_token, user) => {
}
}
// Stored only after the cookie sync: a peer tab reloads when it sees
// another account's token, and its boot signs in from the cookie.
// A token adopted from a peer tab during the sync must not outlive it.
window.auth_token = auth_token;
localStorage.setItem(window.AUTH_TOKEN_KEY_V2, auth_token);
localStorage.removeItem(window.AUTH_TOKEN_KEY_RETIRED);
// Has username changed?
if ( window.user?.username !== user.username )
{
@@ -863,12 +858,6 @@ if ( typeof window !== 'undefined' && window.addEventListener ) {
window.addEventListener('storage', (event) => {
if ( event.key !== window.AUTH_TOKEN_KEY_V2 ) return;
if ( event.newValue ) {
// A peer tab changed accounts. Reload as that account instead of
// sending its token from a desktop still showing this one.
if ( isTokenForAnotherUser(event.newValue, window.user) ) {
window.location.reload();
return;
}
// A peer tab just (re)authed. Pick up the fresh token so
// subsequent requests stop bouncing on reauth_required.
window.auth_token = event.newValue;
@@ -1121,19 +1110,48 @@ window.sort_items = (item_container, sort_by, sort_order) => {
sort_order = 'asc';
}
const direction = sort_order === 'asc' ? 1 : -1;
$(item_container).find('.item[data-sortable="true"]').detach().sort(function (a, b) {
const name_a = a.dataset.name;
const name_b = b.dataset.name;
if ( sort_by === 'type' ) {
const by_extension = compareNames(path.extname(name_a).toLowerCase(), path.extname(name_b).toLowerCase());
return direction * (by_extension || compareNames(name_a, name_b));
// Name
if ( !sort_by || sort_by === 'name' ) {
if ( a.dataset.name.toLowerCase() < b.dataset.name.toLowerCase() ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( a.dataset.name.toLowerCase() > b.dataset.name.toLowerCase() ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
}
return direction * compareEntries(
{ name: name_a, size: parseInt(a.dataset.size), modified: parseInt(a.dataset.modified) },
{ name: name_b, size: parseInt(b.dataset.size), modified: parseInt(b.dataset.modified) },
sort_by,
);
// Size
else if ( sort_by === 'size' ) {
if ( parseInt(a.dataset.size) < parseInt(b.dataset.size) ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( parseInt(a.dataset.size) > parseInt(b.dataset.size) ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
}
// Modified
else if ( sort_by === 'modified' ) {
if ( parseInt(a.dataset.modified) < parseInt(b.dataset.modified) ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( parseInt(a.dataset.modified) > parseInt(b.dataset.modified) ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
}
// Type
else if ( sort_by === 'type' ) {
if ( path.extname(a.dataset.name.toLowerCase()) < path.extname(b.dataset.name.toLowerCase()) ) {
return (sort_order === 'asc' ? -1 : 1);
}
if ( path.extname(a.dataset.name.toLowerCase()) > path.extname(b.dataset.name.toLowerCase()) ) {
return (sort_order === 'asc' ? 1 : -1);
}
return 0;
}
}).appendTo(item_container);
};
@@ -1141,18 +1159,10 @@ window.show_or_hide_files = (item_containers) => {
const show_hidden_files = window.user_preferences.show_hidden_files;
const class_to_add = show_hidden_files ? 'item-revealed' : 'item-hidden';
const class_to_remove = show_hidden_files ? 'item-hidden' : 'item-revealed';
const $dotfiles = $(item_containers)
$(item_containers)
.find('.item')
.filter((_, item) => item.dataset.name.startsWith('.'))
.removeClass(class_to_remove).addClass(class_to_add);
// an item the user can no longer see must not stay in the selection
if ( ! show_hidden_files ) {
$dotfiles.removeClass('item-selected');
}
$(item_containers).closest('.window').each(function () {
window.update_explorer_footer_item_count(this);
window.update_explorer_footer_selected_items_count(this);
});
};
window.create_folder = async (basedir, appendto_element) => {
@@ -1255,9 +1265,6 @@ window.available_templates = () => {
};
window.create_shortcut = async (filename, is_dir, basedir, appendto_element, shortcut_to, shortcut_to_path) => {
// The Shared view is a query, not a directory — nothing can be created in it.
if ( basedir === window.shared_path ) return;
const extname = path.extname(filename);
const basename = `${path.basename(filename, extname) } - Shortcut`;
filename = basename + extname;
@@ -1292,8 +1299,6 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
let progwin;
let latest_status;
const arm_progwin = () => setTimeout(async () => {
// re-armed after a dialog while the window from before it is still open
if ( progwin ) return;
progwin = await UIWindowProgress({
operation_id: copy_op_id,
on_cancel: () => {
@@ -1427,9 +1432,6 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
* @param {string} dest_path - Destination path to copy items to
*/
window.copy_items = function (el_items, dest_path) {
// The Shared view is a query, not a directory — nothing can be copied into it.
if ( dest_path === window.shared_path ) return;
let copy_op_id = window.operation_id++;
let overwrite_all = false;
(async () => {
@@ -1439,8 +1441,6 @@ window.copy_items = function (el_items, dest_path) {
let progwin;
let latest_status;
const arm_progwin = () => setTimeout(async () => {
// re-armed after a dialog while the window from before it is still open
if ( progwin ) return;
progwin = await UIWindowProgress({
operation_id: copy_op_id,
on_cancel: () => {
@@ -1615,37 +1615,20 @@ window.delete_item = async function (el_item, descendants_only = false) {
}
};
window.move_clipboard_items = async function (el_target_container, target_path) {
window.move_clipboard_items = function (el_target_container, target_path) {
let dest_path = target_path === undefined ? $(el_target_container).attr('data-path') : target_path;
const cut_paths = window.clipboard;
window.clipboard = [];
// A cut item's row is gone once its folder is no longer displayed, so
// move_items gets a stand-in carrying what it reads off a row.
const el_items = (await Promise.all(cut_paths.map(async (cut_path) => {
const $el_item = atPath('.item', cut_path);
if ( $el_item.length > 0 ) {
return $el_item;
let el_items = [];
if ( window.clipboard.length > 0 ) {
for ( let i = 0; i < window.clipboard.length; i++ ) {
el_items.push(atPath('.item', window.clipboard[i]));
}
try {
const fsentry = await puter.fs.stat({ path: cut_path, consistency: 'eventual' });
return $('<div>').attr({
'data-uid': fsentry.uid,
'data-path': cut_path,
'data-name': fsentry.name,
'data-is_dir': fsentry.is_dir ? '1' : '0',
'data-immutable': (fsentry.immutable || fsentry.writable === false) ? '1' : '0',
'data-metadata': fsentry.metadata ?? '',
});
} catch ( e ) {
// the item no longer exists, so there is nothing to move
return null;
if ( el_items.length > 0 )
{
window.move_items(el_items, dest_path);
}
}))).filter(Boolean);
if ( el_items.length > 0 ) {
window.move_items(el_items, dest_path);
}
window.clipboard = [];
};
function downloadFile (url, postData = {}) {
@@ -1703,7 +1686,7 @@ window.trigger_download = (paths) => {
let urls = [];
for ( let index = 0; index < paths.length; index++ ) {
urls.push({
download: `${window.origin }/down?path=${ encodeURIComponent(paths[index])}`,
download: `${window.origin }/down?path=${ paths[index]}`,
filename: path.basename(paths[index]),
});
}
@@ -1809,8 +1792,6 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
let progwin;
let latest_status;
const arm_progwin = () => setTimeout(async () => {
// re-armed after a dialog while the window from before it is still open
if ( progwin ) return;
progwin = await UIWindowProgress({
operation_id: move_op_id,
on_cancel: () => {
@@ -1858,8 +1839,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
if ( path.dirname($(el_item).attr('data-path')) === item_dest_path ) {
// pause the progress-window timer while waiting for the user
clearTimeout(progwin_timeout);
// UIAlert escapes the message itself
await UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>Cannot move item to its current location.`);
await UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>Cannot move item to its current location.`);
progwin_timeout = arm_progwin();
continue;
@@ -1933,7 +1913,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
}
// moving an item into a trashed directory? deny.
else if ( dest_path === window.trash_path || dest_path.startsWith(`${window.trash_path}/`) ) {
else if ( dest_path.startsWith(window.trash_path) ) {
// the pending timer would otherwise open an orphan
// progress window after the operation already bailed
clearTimeout(progwin_timeout);
@@ -2018,7 +1998,6 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
underPath('.window', moved_from_path).add(atPath('.window', moved_from_path)).each(function () {
window.update_window_path(this, path.join(item_dest_path, fsentry.name) + $(this).attr('data-path').slice(moved_from_path.length));
});
retargetNavHistory(moved_from_path, fsentry.path);
}
// The dashboard's Back/Forward follows a moved folder, and
@@ -2046,19 +2025,14 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// if this is trash, get original name from item metadata
fsentry.name = metadata?.original_name || fsentry.name;
const moved_item_icon = await item_icon(fsentry);
const $dest_containers = atPath('.item-container', item_dest_path);
// the item.moved echo of this move can render the row first
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
// create new item on matching containers
const options = {
appendTo: $dest_containers,
appendTo: atPath('.item-container', item_dest_path),
immutable: fsentry.immutable || (fsentry.writable === false),
associated_app_name: fsentry.associated_app?.name,
uid: fsentry.uid,
path: fsentry.path,
icon: moved_item_icon,
icon: await item_icon(fsentry),
name: is_trashing ? $(el_item).attr('data-name') : fsentry.name,
is_dir: fsentry.is_dir,
size: fsentry.size,
@@ -2153,7 +2127,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
item_with_same_name_already_exists = false;
// error message after source item has reappeared
$(el_item).show(0, function () {
UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>${err.message ?? ''}`);
UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>${err.message ?? ''}`);
});
break;
@@ -2416,9 +2390,6 @@ window.upload_items = async function (items, dest_path) {
let upload_progress_window;
let opid;
// The Shared view is a query, not a directory — nothing can be uploaded into it.
if ( dest_path === window.shared_path ) return;
if ( dest_path == window.trash_path ) {
UIAlert('Uploading to trash is not allowed!');
return;
@@ -2898,15 +2869,13 @@ window.unzipItem = async function (itemPath) {
terminateOp = fflate.unzip(file, async (err, unzipped) => {
currentProgress += window.zippingProgressConfig.ZIPPING;
progwin?.set_progress(currentProgress.toPrecision(2));
const close_progwin = (hide_delay) => {
if ( err ) {
UIAlert(e.message);
// close progress window
clearTimeout(progwin_timeout);
setTimeout(() => {
progwin?.close();
}, Math.max(0, hide_delay - (Date.now() - start_ts)));
};
if ( err ) {
UIAlert(err.message);
close_progwin(window.copy_progress_hide_delay);
}, Math.max(0, window.copy_progress_hide_delay - (Date.now() - start_ts)));
} else {
const rootdir = await puter.fs.mkdir(`${path.dirname(filePath) }/${ path.basename(filePath, '.zip')}`, { dedupeName: true });
let perItemProgress = window.zippingProgressConfig.WRITING / Object.keys(unzipped).length;
@@ -2924,10 +2893,6 @@ window.unzipItem = async function (itemPath) {
UIAlert(e.message);
}
});
// an archive with no files leaves nothing to upload, so nothing else would end the operation
if ( queuedFileWrites.length === 0 ) {
close_progwin(window.unzip_progress_hide_delay);
}
queuedFileWrites.length && puter.fs.upload(
// what to upload
queuedFileWrites,
@@ -2939,7 +2904,7 @@ window.unzipItem = async function (itemPath) {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
progress: async function (operation_id, op_progress) {
progwin?.set_progress(op_progress);
progwin.set_progress(op_progress);
// update title if window is not visible
if ( document.visibilityState !== 'visible' ) {
update_title_based_on_uploads();
@@ -2947,10 +2912,11 @@ window.unzipItem = async function (itemPath) {
},
success: async function (items) {
progwin?.set_progress(window.zippingProgressConfig.TOTAL.toPrecision(2));
close_progwin(window.unzip_progress_hide_delay);
},
error: function () {
close_progwin(window.unzip_progress_hide_delay);
// close progress window
clearTimeout(progwin_timeout);
setTimeout(() => {
progwin?.close();
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
},
},
);
@@ -3182,13 +3148,6 @@ window.untarItem = async function (itemPath) {
}
}
// an archive with no files leaves nothing to upload, so nothing else would end the operation
if ( queuedFileWrites.length === 0 ) {
clearTimeout(progwin_timeout);
setTimeout(() => {
progwin?.close();
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
}
queuedFileWrites.length && puter.fs.upload(
queuedFileWrites,
`${rootdir.path }/`,
@@ -3197,7 +3156,7 @@ window.untarItem = async function (itemPath) {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
progress: async function (operation_id, op_progress) {
progwin?.set_progress(op_progress);
progwin.set_progress(op_progress);
if ( document.visibilityState !== 'visible' ) {
update_title_based_on_uploads();
}
@@ -3416,9 +3375,7 @@ window.undo_last_action = async () => {
delete: () => window.undo_delete(data),
rename: () => {
const { options, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, onDone } = data;
// the item now sits at its renamed path, which is what this rename moves away from
const renamed_path = path.join(path.dirname(old_path), new_name);
window.rename_file(options, old_name, new_name, renamed_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
window.rename_file(options, old_name, new_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
},
};
+1 -23
View File
@@ -37,7 +37,7 @@ globalThis.html_encode = value => jQuery('<div>').text(value).html();
await import('./helpers.js');
const { default: apply_item_added_to_containers } = await import('./helpers/applyItemAddedToContainers.js');
const { default: UIItem } = await import('./UI/UIItem.js');
const { atPath, retargetNavHistory, retargetPaths, underPath } = await import('./helpers/pathElements.js');
const { atPath, retargetPaths, underPath } = await import('./helpers/pathElements.js');
// A plain name, then names holding characters a selector or HTML treats specially.
const NAMES = ['Reports 2024', 'R&D', "Dan's files", 'the "big" one', 'a<b>c', 'back\\slash'];
@@ -146,25 +146,3 @@ describe.each([undefined, null, ''])('a lookup for the path %s', (missing) => {
expect(underPath('.window', missing)).toHaveLength(0);
});
});
describe('retargetNavHistory', () => {
it('rewrites entries at or inside the old path and leaves the rest', () => {
window.window_nav_history = {
1: ['/alice', '/alice/A', '/alice/A/sub', '/alice/AB', 'puter://shared'],
2: ['/alice/Documents'],
};
retargetNavHistory('/alice/A', '/alice/B');
expect(window.window_nav_history[1]).toEqual(['/alice', '/alice/B', '/alice/B/sub', '/alice/AB', 'puter://shared']);
expect(window.window_nav_history[2]).toEqual(['/alice/Documents']);
});
it('is applied by retargetPaths', () => {
window.window_nav_history = { 1: ['/alice/A/sub'] };
retargetPaths('/alice/A', '/alice/B', 'B');
expect(window.window_nav_history[1]).toEqual(['/alice/B/sub']);
});
});
-19
View File
@@ -17,8 +17,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import path from '../lib/path.js';
/**
* Expands a user-facing path into the absolute path the filesystem API takes:
* `~` and `~/x` resolve under the user's home, a bare relative path is read as
@@ -41,20 +39,3 @@ export const expand_home_path = (fspath, home_path) => {
if ( fspath.startsWith('/') ) return fspath;
return `${home}/${fspath}`;
};
/**
* Turns what a user typed into an address bar into the canonical absolute
* path: trimmed, home-expanded, with `.`/`..`, doubled and trailing slashes
* resolved. An empty entry keeps the current path.
*
* @param {string} typed_path - the raw text the user entered
* @param {{ current_path: string, home_path: string, shared_path: string }} context
* @returns {string} the path to navigate to
*/
export const resolve_typed_path = (typed_path, { current_path, home_path, shared_path }) => {
const trimmed = typeof typed_path === 'string' ? typed_path.trim() : '';
if ( trimmed === '' ) return current_path;
// The Shared view is a pseudo-path, not a filesystem one.
if ( trimmed === shared_path ) return shared_path;
return path.resolve('/', expand_home_path(trimmed, home_path));
};
+1 -26
View File
@@ -18,7 +18,7 @@
*/
import { describe, it, expect } from 'vitest';
import { expand_home_path, resolve_typed_path } from './expandHomePath.js';
import { expand_home_path } from './expandHomePath.js';
describe('expand_home_path', () => {
it('resolves a tilde path under the home path', () => {
@@ -57,28 +57,3 @@ describe('expand_home_path', () => {
expect(expand_home_path(null, '/alice')).toBe(null);
});
});
describe('resolve_typed_path', () => {
const context = { current_path: '/alice/Videos', home_path: '/alice', shared_path: 'puter://shared' };
it('canonicalizes trailing, doubled and dot segments', () => {
expect(resolve_typed_path('/alice/Documents/', context)).toBe('/alice/Documents');
expect(resolve_typed_path('/alice//Documents', context)).toBe('/alice/Documents');
expect(resolve_typed_path('/alice/Documents/../Pictures', context)).toBe('/alice/Pictures');
expect(resolve_typed_path('/', context)).toBe('/');
});
it('trims whitespace and expands home-relative entries', () => {
expect(resolve_typed_path(' ~/Documents ', context)).toBe('/alice/Documents');
expect(resolve_typed_path('Documents', context)).toBe('/alice/Documents');
});
it('keeps the current path for an empty entry', () => {
expect(resolve_typed_path('', context)).toBe('/alice/Videos');
expect(resolve_typed_path(' ', context)).toBe('/alice/Videos');
});
it('passes the Shared view through untouched', () => {
expect(resolve_typed_path('puter://shared', context)).toBe('puter://shared');
});
});
+8 -2
View File
@@ -18,7 +18,6 @@
*/
import { isAttestedOrigin } from './attestedOrigin.js';
import { jwtPayload } from './jwtPayload.js';
/**
* Godmode apps that run on their own full-access token instead of the
@@ -74,7 +73,14 @@ export const shouldRenew = (expiresAt, nowMs) =>
!Number.isFinite(expiresAt) || expiresAt * 1000 - nowMs < RENEW_WHEN_LEFT_MS;
/** The token row a JWT belongs to; renewals of a live row keep it. */
const tokenRowOf = (token) => jwtPayload(token)?.token_uid ?? null;
const tokenRowOf = (token) => {
try {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(part))?.token_uid ?? null;
} catch {
return null;
}
};
/**
* Whether two tokens are the same row, so the app already holds a working one.
+1 -1
View File
@@ -100,7 +100,7 @@ const item_icon = async (fsentry) => {
// --------------------------------------------------
// .app files
// --------------------------------------------------
else if ( !fsentry.is_dir && fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
else if ( fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
try {
const content = await puter.fs.read({ path: fsentry.path });
const text = typeof content === 'string' ? content : await content.text();
-35
View File
@@ -1,35 +0,0 @@
/*
* 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/>.
*/
/**
* The claims of a JWT, read without checking its signature, so they say what
* the token claims and prove nothing.
*
* @param {string} token
* @returns {Record<string, unknown> | null} null when the token isn't a readable JWT
*/
export const jwtPayload = (token) => {
try {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(part));
return payload && typeof payload === 'object' ? payload : null;
} catch {
return null;
}
};
+1 -6
View File
@@ -145,14 +145,9 @@ const new_context_menu_item = function (dirname, append_to_element) {
canvas.width = 800;
canvas.height = 600;
// JPEG has no transparency, so the blank image needs a background
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
window.create_file({ dirname: dirname, append_to_element: append_to_element, name: 'New Image.jpg', content: blob });
}, 'image/jpeg');
});
},
},
// Worker
+1 -2
View File
@@ -19,9 +19,8 @@
import UIWindow from '../UI/UIWindow.js';
import UIAlert from '../UI/UIAlert.js';
import i18n from '../i18n/i18n.js';
import launch_app from './launchApp.js';
// installs the global i18n()
import '../i18n/i18n.js';
import path from '../lib/path.js';
import item_icon from './itemIcon.js';
import { resolve_default_app } from './defaultApp.js';
+1 -15
View File
@@ -23,6 +23,7 @@ import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn(async () => undefined) }));
vi.mock('../i18n/i18n.js', () => ({ default: key => key }));
vi.mock('./launchApp.js', () => ({ default: vi.fn(async () => ({ launchResult: { launched: true } })) }));
vi.mock('./itemIcon.js', () => ({ default: vi.fn() }));
vi.mock('./defaultApp.js', () => ({ resolve_default_app: vi.fn(async () => null) }));
@@ -112,18 +113,3 @@ describe('open_item with a new tab', () => {
expect(tab.close).not.toHaveBeenCalled();
});
});
describe('open_item with no app for the file', () => {
it('offers to download it', async () => {
window.is_dashboard_mode = false;
window.trigger_download = vi.fn();
UIAlert.mockResolvedValueOnce('download_file');
await open_item({ item: row({ name: 'data.bin', path: '/user/data.bin' }) });
expect(UIAlert).toHaveBeenCalledTimes(1);
expect(UIAlert.mock.calls[0][1].map(button => button.value ?? button.label)).toEqual(['download_file', 'Cancel']);
expect(window.trigger_download).toHaveBeenCalledWith(['/user/data.bin']);
});
});
-19
View File
@@ -70,23 +70,4 @@ export const retargetPaths = (oldPath, newPath, newName) => {
$inside.each(function () {
$(this).attr('data-path', newPath + $(this).attr('data-path').slice(oldPath.length));
});
retargetNavHistory(oldPath, newPath);
};
/**
* Points every window's Back/Forward entries at `oldPath`, or inside it, to
* `newPath`, so they still lead somewhere after a rename or move.
*
* @param {string} oldPath
* @param {string} newPath
*/
export const retargetNavHistory = (oldPath, newPath) => {
if ( ! oldPath ) return;
const prefix = `${oldPath}/`;
for ( const history of Object.values(window.window_nav_history ?? {}) ) {
history.forEach((entry, index) => {
if ( entry === oldPath ) history[index] = newPath;
else if ( entry.startsWith(prefix) ) history[index] = newPath + entry.slice(oldPath.length);
});
}
};
+1 -16
View File
@@ -72,13 +72,9 @@ const refresh_item_container = function (el_item_container, options) {
}
// show the loading spinner
$(loading_spinner).find('.explorer-loading-spinner-msg').text(`${i18n('loading')}...`);
$(loading_spinner).show();
setTimeout(function () {
// only a load that is still running has taken long
if ( is_current() && $(loading_spinner).is(':visible') ) {
$(loading_spinner).find('.explorer-loading-spinner-msg').text(i18n('taking_longer_than_usual'));
}
$(loading_spinner).find('.explorer-loading-spinner-msg').html('Taking a little longer than usual. Please wait...');
}, 3000);
}, 1000);
@@ -102,8 +98,6 @@ const refresh_item_container = function (el_item_container, options) {
return;
}
if ( el_window ) {
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
$(el_window).attr('data-uid', fsentry.id);
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
@@ -126,8 +120,6 @@ const refresh_item_container = function (el_item_container, options) {
if ( fsentry.layout === 'details' ) {
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
}
}).catch(() => {
// The listing request below reports the failure to the user.
});
// is_directoryPicker
@@ -143,8 +135,6 @@ const refresh_item_container = function (el_item_container, options) {
// remove all existing items
$(el_item_container).find('.item').removeItems();
// removing the last row shows the empty message, which is wrong while the folder is loading
$(empty_message).hide();
// get items with subdomains/workers included to avoid per-item stat calls
const entries_promise = is_shared_view
@@ -362,11 +352,6 @@ const refresh_item_container = function (el_item_container, options) {
$(error_message).html(`Failed to load directory${ html_encode((e && e.message ? `: ${ e.message}` : ''))}`);
$(error_message).show();
// the previous folder's items are gone, so its count must not linger
if ( el_window ) {
window.update_explorer_footer_item_count(el_window);
}
// Call onComplete callback even in error case, since the "loading" is technically complete
if ( options.onComplete && typeof options.onComplete === 'function' ) {
options.onComplete();
-66
View File
@@ -1,66 +0,0 @@
/*
* 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 { jwtPayload } from './jwtPayload.js';
const UUID_HEX = /^[0-9a-f]{32}$/;
/** @param {unknown} uuid */
const uuidHex = (uuid) => {
if ( typeof uuid !== 'string' ) return null;
const hex = uuid.replace(/-/g, '').toLowerCase();
return UUID_HEX.test(hex) ? hex : null;
};
/**
* The uuid of the user a session token was issued to. The token carries it as
* the `uu` claim: the uuid's 16 bytes in base64, or the dashed uuid itself.
*
* @param {string} token
* @returns {string | null} dashless lowercase hex, or null when the token doesn't say
*/
const tokenUserHex = (token) => {
const claim = jwtPayload(token)?.uu;
if ( typeof claim !== 'string' || !claim ) return null;
if ( claim.includes('-') ) return uuidHex(claim);
try {
const bytes = atob(claim);
let hex = '';
for ( let i = 0; i < bytes.length; i++ ) {
hex += bytes.charCodeAt(i).toString(16).padStart(2, '0');
}
return UUID_HEX.test(hex) ? hex : null;
} catch {
return null;
}
};
/**
* Whether a session token was issued to a user other than `user`. False when
* either side can't be read, so an unreadable token is never taken for a switch.
*
* @param {string} token
* @param {{ uuid?: string } | null | undefined} user
* @returns {boolean}
*/
export const isTokenForAnotherUser = (token, user) => {
const tokenUser = tokenUserHex(token);
const currentUser = uuidHex(user?.uuid);
return !!tokenUser && !!currentUser && tokenUser !== currentUser;
};
-69
View File
@@ -1,69 +0,0 @@
/*
* 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 { describe, expect, it } from 'vitest';
import { isTokenForAnotherUser } from './tokenUser.js';
const ALICE = '5d4adce0-a381-4982-9c02-6e2540026238';
const BOB = 'a1b2c3d4-0000-4000-8000-00000000beef';
const jwtWith = (claims) =>
`h.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.s`;
// The claim as the server writes it: the uuid's bytes in base64.
const tokenFor = (uuid) =>
jwtWith({ uu: Buffer.from(uuid.replace(/-/g, ''), 'hex').toString('base64') });
describe('isTokenForAnotherUser', () => {
it('is true for a token issued to a different user', () => {
expect(isTokenForAnotherUser(tokenFor(BOB), { uuid: ALICE })).toBe(true);
});
it('is false for a token issued to the same user', () => {
expect(isTokenForAnotherUser(tokenFor(ALICE), { uuid: ALICE })).toBe(false);
});
it('compares uuids regardless of letter case', () => {
expect(isTokenForAnotherUser(tokenFor(ALICE), { uuid: ALICE.toUpperCase() })).toBe(false);
});
it('reads a claim that holds the dashed uuid itself', () => {
expect(isTokenForAnotherUser(jwtWith({ uu: BOB }), { uuid: ALICE })).toBe(true);
expect(isTokenForAnotherUser(jwtWith({ uu: ALICE }), { uuid: ALICE })).toBe(false);
});
it('is false when no user is signed in here', () => {
expect(isTokenForAnotherUser(tokenFor(BOB), null)).toBe(false);
expect(isTokenForAnotherUser(tokenFor(BOB), undefined)).toBe(false);
expect(isTokenForAnotherUser(tokenFor(BOB), {})).toBe(false);
});
it('is false when the token does not name a user', () => {
for ( const token of [
jwtWith({}),
jwtWith({ uu: '' }),
jwtWith({ uu: 42 }),
jwtWith({ uu: 'AAAA' }),
jwtWith({ uu: '!!not base64!!' }),
'not-a-jwt',
'',
] ) {
expect(isTokenForAnotherUser(token, { uuid: ALICE })).toBe(false);
}
});
});
-1
View File
@@ -1067,7 +1067,6 @@ const en = {
'window_click_to_go_back': 'Click to go back.',
'window_click_to_go_forward': 'Click to go forward.',
'window_click_to_go_up': 'Click to go one directory up.',
'window_click_to_change_layout': 'Click to change the layout.',
'window_title_public': 'Public',
'window_title_videos': 'Videos',
'window_title_pictures': 'Pictures',
-45
View File
@@ -69,51 +69,6 @@ describe('the popup token hand-off in postAuthActions', () => {
});
});
describe('the requests a sign-in popup makes after an account is picked', () => {
const postAuthActions = src.slice(
src.indexOf('const postAuthActions = async'),
src.indexOf('window.initgui = async'),
);
it('matches the early exchange before every exchange in postAuthActions', () => {
const exchanges = postAuthActions.split('await window.getUserAppToken(origin)').length - 1;
const matched = postAuthActions.split(
'reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??',
).length - 1;
expect(exchanges).toBe(2);
expect(matched).toBe(2);
expect(postAuthActions.split('getUserAppToken(').length - 1).toBe(2);
});
it('records every early exchange, so none is repeated unmatched', () => {
const boot = src.slice(src.indexOf('window.initgui = async'));
const sdkLogin = boot.slice(
boot.indexOf('// show signup window'),
boot.indexOf('window.popup_signin_consent = !!picked_a_user_for_sdk_login;'),
);
expect(sdkLogin.split('await exchangeUserAppTokenEarly();').length - 1).toBe(3);
expect(sdkLogin).not.toContain('getUserAppToken(');
});
it('skips the relationship check only for a picked account', () => {
// Signup in the popup sets consent without a pick, and still needs
// the check to keep a second account picker away.
const check = src.indexOf('await window.checkUserSiteRelationship(');
const guard = src.lastIndexOf('if (', check);
expect(src.slice(guard, check)).toContain('if (!picked_a_user_for_sdk_login) {');
expect(src.slice(guard, check)).not.toContain('popup_signin_consent');
});
it('reuses the early whoami only through reusablePopupUser', () => {
const reuse = src.indexOf('whoami = popup_whoami.user;');
expect(reuse).toBeGreaterThan(-1);
expect(src.split('popup_whoami.user').length - 1).toBe(1);
const guard = src.slice(src.lastIndexOf('if (', reuse), reuse);
expect(guard).toContain('picked_a_user_for_sdk_login &&');
expect(guard).toContain('reusablePopupUser(popup_whoami, {');
});
});
describe('the permission popup and browser push', () => {
const postAuthActions = src.slice(
src.indexOf('const postAuthActions = async'),
+9 -59
View File
@@ -112,8 +112,6 @@ import {
relationshipIsConsent,
relaysTokenAtBoot,
resolveOpenerOrigin,
reusablePopupUser,
reusableTokenExchange,
runsUserAppTokenExchange,
} from './util/popupAuth.js';
import { verifyOidcPopupReturn } from './util/popupOidcReturn.js';
@@ -163,21 +161,6 @@ const follow_share_recipient = async (action) => {
}));
};
// A sign-in popup exchanges as soon as the user has chosen an account, and
// `postAuthActions` hands over that result instead of exchanging again.
let early_token_exchange = null;
const exchangeUserAppTokenEarly = async () => {
const authToken = window.auth_token;
const data = await window.getUserAppToken(window.openerOrigin);
let origin = null;
try {
origin = new URL(window.openerOrigin).origin;
} catch (e) {
// No origin to match on, so the result is not reused.
}
early_token_exchange = { origin, authToken, data };
};
const postAuthActions = async (action) => {
if ( await follow_share_recipient(action) ) return;
// Set when a popup's user-app token exchange fails. The exchange is what
@@ -457,10 +440,7 @@ const postAuthActions = async (action) => {
if (isolated) {
try {
const origin = new URL(window.openerOrigin).origin;
const data =
reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??
await window.getUserAppToken(origin);
const data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
// Same two failure modes the postMessage path below guards
// against: `getUserAppToken` reports a network failure by
// returning null, and an HTTP failure (a blocked origin, an
@@ -504,10 +484,7 @@ const postAuthActions = async (action) => {
// Its failure carries `token: null`, which the SDK sets too.
const deliver_token_to_opener = deliversTokenAtBoot(action);
try {
const origin = new URL(window.openerOrigin).origin;
let data =
reusableTokenExchange(early_token_exchange, origin, window.auth_token) ??
await window.getUserAppToken(origin);
let data = await window.getUserAppToken(new URL(window.openerOrigin).origin);
// `getUserAppToken` reports a network failure by returning
// null, and an HTTP failure (a blocked origin, a 5xx) by
// returning the parsed *error* body — which is truthy but
@@ -1537,8 +1514,6 @@ window.initgui = async function (options) {
let picked_a_user_for_sdk_login = false;
// Whether a popup put an account picker in front of the user.
let showed_account_picker = false;
// The `/whoami` a sign-in popup read before its account picker.
let popup_whoami = null;
// update SDK if auth_token is different from the one in the SDK
if (window.auth_token && puter.authToken !== window.auth_token) {
@@ -1893,7 +1868,7 @@ window.initgui = async function (options) {
// Completing signup in a sign-in popup is the user asking to
// be signed in to the opener.
window.popup_signin_consent = true;
await exchangeUserAppTokenEarly();
await window.getUserAppToken(window.openerOrigin);
}
} else if (
// An action-less popup is a sign-in popup — `postAuthActions`
@@ -1917,11 +1892,6 @@ window.initgui = async function (options) {
whoami_popup.token || window.auth_token,
whoami_popup,
);
popup_whoami = {
user: whoami_popup,
authToken: window.auth_token,
fetchedAt: Date.now(),
};
} catch (e) {
// session/auth errors will be handled further ahead;
// let's log the error for now in case a change in state occurred.
@@ -1947,7 +1917,7 @@ window.initgui = async function (options) {
String(currentUserUuid);
if (proofMatchesCurrentUser) {
picked_a_user_for_sdk_login = true;
await exchangeUserAppTokenEarly();
await window.getUserAppToken(window.openerOrigin);
} else {
// Show session list so user can pick which account to use
showed_account_picker = true;
@@ -1957,7 +1927,7 @@ window.initgui = async function (options) {
});
if (picked_a_user_for_sdk_login) {
await exchangeUserAppTokenEarly();
await window.getUserAppToken(window.openerOrigin);
}
}
// Picking an account here *is* the consent to sign the opener in.
@@ -2080,14 +2050,10 @@ window.initgui = async function (options) {
// if not, we need to ask the user for confirmation before proceeding BUT only if the action is a file-picker action
// -------------------------------------------------------------------------------------
if (window.embedded_in_popup && window.openerOrigin) {
// An account picked above already answers both questions below, the
// picker and the consent. Signup in the popup is not a pick.
if (!picked_a_user_for_sdk_login) {
let response = await window.checkUserSiteRelationship(
window.openerOrigin,
);
window.userAppToken = response?.token;
}
let response = await window.checkUserSiteRelationship(
window.openerOrigin,
);
window.userAppToken = response?.token;
// A framed picker or upgrade already holds the framing page's user,
// so it doesn't offer to switch.
@@ -2444,22 +2410,6 @@ window.initgui = async function (options) {
// Authed
// -------------------------------------------------------------------------------------
if (window.is_auth()) {
// A sign-in popup whose user kept the account it opened with, and whose
// exchange just proved the session, already has this user's data.
if (
!whoami &&
picked_a_user_for_sdk_login &&
reusablePopupUser(popup_whoami, {
userUuid: window.user?.uuid,
authToken: window.auth_token,
exchangedAuthToken: early_token_exchange?.data?.token
? early_token_exchange.authToken
: null,
now: Date.now(),
})
) {
whoami = popup_whoami.user;
}
// try to get user data using /whoami, only if that data is missing
if (!whoami) {
try {
+31 -50
View File
@@ -23,25 +23,6 @@ import launch_app from './helpers/launchApp.js';
import open_item from './helpers/openItem.js';
import determine_active_container_parent from './helpers/determineActiveContainerParent.js';
// The item container the user is working in: around or inside the element they
// last pressed, or elsewhere in its window. A pressed item that the window has
// since navigated away from leaves only the container it was in.
const selection_container = () => {
const $active = $(window.active_element);
let $container = $active.closest('.item-container');
if ( $container.length === 0 ) {
$container = $active.find('.item-container');
}
if ( $container.length === 0 ) {
$container = $active.closest('.window').find('.item-container');
}
if ( $container.length === 0 && window.active_element && !document.contains(window.active_element)
&& document.contains(window.active_item_container) ) {
$container = $(window.active_item_container);
}
return $container;
};
$(document).bind('keydown', async function (e) {
const focused_el = document.activeElement;
//-----------------------------------------------------------------------------
@@ -309,14 +290,13 @@ $(document).bind('keydown', async function (e) {
if ( selected.length === 1 ) {
return selected.get(0);
}
// only an item that is still selected in this container can anchor the move
if ( selected.is(window.latest_selected_item) ) {
if ( selected.length > 1 && window.latest_selected_item ) {
return window.latest_selected_item;
}
if ( selected.is(window.active_element) ) {
if ( window.active_element && $(window.active_element).hasClass('item') ) {
return window.active_element;
}
return selected.get(-1);
return $(window.active_item_container).find('.item').get(0);
}
function findNeighbor (current, direction) {
@@ -383,19 +363,18 @@ $(document).bind('keydown', async function (e) {
// select first item if none are already selected
const selected = $(window.active_item_container).find('.item-selected');
if ( selected.length === 0 ) {
const first = $(window.active_item_container).find('.item:not(.item-hidden)').get(0);
const first = $(window.active_item_container).find('.item').get(0);
if ( first ) {
$(first).addClass('item-selected');
window.active_element = first;
window.latest_selected_item = first;
first.scrollIntoView({ block: 'nearest', inline: 'nearest' });
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
}
return;
}
// virtual grid layout to determine item layout and next items
const items = Array.from($(window.active_item_container).find('.item:not(.item-hidden)'));
const items = Array.from($(window.active_item_container).find('.item'));
const grid = items.map(item => {
const rect = item.getBoundingClientRect();
return {
@@ -414,7 +393,6 @@ $(document).bind('keydown', async function (e) {
const currentEl = getActiveItem();
const current = grid.find(i => i.el === currentEl);
if ( ! current ) return;
const next = findNeighbor(current, dir);
// apply new selection(s)
@@ -431,7 +409,6 @@ $(document).bind('keydown', async function (e) {
$(next.el).addClass('item-selected');
}
next.el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
}
}
// ----------------------------------------------
@@ -523,15 +500,14 @@ $(document).bind('keydown', async function (e) {
// Shift+Delete (win)/ option+command+delete (Mac) key pressed
// Permanent delete bypassing trash after alert
//-----------------------------------------------------------------------
if ( ((e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8)) && !$(focused_el).is('input, textarea') ) {
let $selected_items = selection_container().find('.item-selected');
if ( (e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8) ) {
let $selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
if ( $selected_items.length > 0 ) {
const alert_resp = await UIAlert({
message: i18n('confirm_delete_multiple_items'),
buttons: [
{
label: i18n('delete'),
value: 'delete',
type: 'primary',
},
{
@@ -539,7 +515,7 @@ $(document).bind('keydown', async function (e) {
},
],
});
if ( (alert_resp) === 'delete' ) {
if ( (alert_resp) === 'Delete' ) {
for ( let index = 0; index < $selected_items.length; index++ ) {
const element = $selected_items[index];
await window.delete_item(element);
@@ -552,16 +528,15 @@ $(document).bind('keydown', async function (e) {
// Delete (win)/ ctrl+delete (Mac) / cmd+delete (Mac) key pressed
// Permanent delete from trash after alert or move to trash
//-----------------------------------------------------------------------
if ( (e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey))) && !$(focused_el).is('input, textarea') ) {
if ( e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey)) ) {
// permanent delete?
let $selected_items = selection_container().find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
let $selected_items = $(window.active_element).closest('.item-container').find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
if ( $selected_items.length > 0 ) {
const alert_resp = await UIAlert({
message: i18n('confirm_delete_multiple_items'),
buttons: [
{
label: i18n('delete'),
value: 'delete',
type: 'primary',
},
{
@@ -569,7 +544,7 @@ $(document).bind('keydown', async function (e) {
},
],
});
if ( (alert_resp) === 'delete' ) {
if ( (alert_resp) === 'Delete' ) {
for ( let index = 0; index < $selected_items.length; index++ ) {
const element = $selected_items[index];
await window.delete_item(element);
@@ -579,7 +554,7 @@ $(document).bind('keydown', async function (e) {
}
// regular delete?
else {
$selected_items = selection_container().find('.item-selected');
$selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
if ( $selected_items.length > 0 ) {
// Only delete the items if we're not renaming one.
if ( $selected_items.children('.item-name-editor-active').length === 0 ) {
@@ -594,11 +569,6 @@ $(document).bind('keydown', async function (e) {
// A letter or number is pressed and there is no context menu open: search items by name
//-----------------------------------------------------------------------
if ( !e.ctrlKey && !e.metaKey && !$(focused_el).is('input') && !$(focused_el).is('textarea') && $('.context-menu').length === 0 ) {
// Shift, arrows, Enter and other named keys are not part of a name
if ( e.key?.length !== 1 ) {
return false;
}
if ( window.keypress_item_seach_term !== '' )
{
clearTimeout(window.keypress_item_seach_buffer_timeout);
@@ -619,7 +589,7 @@ $(document).bind('keydown', async function (e) {
}
// search for matches
let haystack = $(window.active_item_container).find('.item').not('.item-disabled, .item-hidden');
let haystack = $(window.active_item_container).find('.item').not('.item-disabled');
for ( let j = 0; j < haystack.length; j++ ) {
if ( $(haystack[j]).attr('data-name').toLowerCase().startsWith(window.keypress_item_seach_term) ) {
matches.push(haystack[j]);
@@ -643,7 +613,7 @@ $(document).bind('keydown', async function (e) {
// select matching item
$(matches[0]).not('.item-disabled').addClass('item-selected');
matches[0].scrollIntoView(false);
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
window.update_explorer_footer_selected_items_count($(window.active_element).closest('.window'));
}
return false;
@@ -717,7 +687,11 @@ $(document).bind('keyup keydown', async function (e) {
// ctrl/command + a, will select all items on desktop and windows
//-----------------------------------------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 65 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
const $parent_container = selection_container();
let $parent_container = $(window.active_element).closest('.item-container');
if ( $parent_container.length === 0 )
{
$parent_container = $(window.active_element).find('.item-container');
}
if ( $parent_container.attr('data-multiselectable') === 'false' )
{
@@ -725,7 +699,7 @@ $(document).bind('keyup keydown', async function (e) {
}
if ( $parent_container ) {
$($parent_container).find('.item').not('.item-disabled, .item-hidden').addClass('item-selected');
$($parent_container).find('.item').not('.item-disabled').addClass('item-selected');
window.update_explorer_footer_selected_items_count($parent_container.closest('.window'));
}
@@ -758,7 +732,11 @@ $(document).bind('keyup keydown', async function (e) {
!$(focused_el).is('textarea') ) {
let $selected_items;
const parent_container = selection_container();
let parent_container = $(window.active_element).closest('.item-container');
if ( parent_container.length === 0 )
{
parent_container = $(window.active_element).find('.item-container');
}
if ( parent_container !== null ) {
$selected_items = $(parent_container).find('.item-selected');
@@ -783,7 +761,11 @@ $(document).bind('keyup keydown', async function (e) {
//-----------------------------------------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 88 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
let $selected_items;
const parent_container = selection_container();
let parent_container = $(window.active_element).closest('.item-container');
if ( parent_container.length === 0 )
{
parent_container = $(window.active_element).find('.item-container');
}
if ( parent_container !== null ) {
$selected_items = $(parent_container).find('.item-selected');
@@ -881,8 +863,7 @@ $(document).bind('keyup keydown', async function (e) {
// Paste
// ctrl/command + v, will paste items from the clipboard to the active element
//----------------------------------------------
// Once per press: releasing V with Ctrl still held would paste a second time.
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && e.type === 'keydown' && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
let target_path, target_el;
// continue only if there is something in the clipboard
-59
View File
@@ -135,65 +135,6 @@ export const resolveOpenerOrigin = async ({
export const relationshipIsConsent = ({ hasRelationship, showedAccountPicker }) =>
hasRelationship && !showedAccountPicker;
/**
* The result of a popup's early user-app token exchange, when it stands in for
* a second one: it returned a token, for the same opener and the same session.
*
* @param {{
* origin: string|null;
* authToken: string|null|undefined;
* data: { token?: string; app_uid?: string }|null;
* }|null} early - The early exchange, or null when none ran.
* @param {string} origin - The opener origin the token is needed for.
* @param {string|null|undefined} authToken - The session token in use.
* @returns {{ token: string; app_uid?: string }|null} null means exchange again.
*/
export const reusableTokenExchange = (early, origin, authToken) => {
if ( !early?.data?.token || !origin || !authToken ) return null;
if ( early.origin !== origin || early.authToken !== authToken ) return null;
return /** @type {{ token: string; app_uid?: string }} */ (early.data);
};
/** How long a sign-in popup's first `/whoami` stays usable. */
export const POPUP_WHOAMI_MAX_AGE_MS = 60_000;
const VERIFICATION_GATE_FLAGS = [
'requires_email_confirmation',
'requires_phone_verification',
'requires_card_verification',
'requires_password_change',
];
/**
* Whether the `/whoami` a sign-in popup read before its account picker still
* describes the session, so boot can skip reading it again.
*
* A pending verification gate always gets a fresh read, and so does a session
* the token exchange has not just accepted.
*
* @param {{
* user: Record<string, unknown>;
* authToken: string|null|undefined;
* fetchedAt: number;
* }|null} early - The early read, or null when none succeeded.
* @param {{
* userUuid: string|null|undefined;
* authToken: string|null|undefined;
* exchangedAuthToken: string|null|undefined;
* now: number;
* }} session - The signed-in user and token now, and the session token the
* early token exchange succeeded with (null when it returned no token).
* @returns {boolean}
*/
export const reusablePopupUser = (early, { userUuid, authToken, exchangedAuthToken, now }) => {
if ( !early?.user?.uuid || !authToken ) return false;
if ( early.user.uuid !== userUuid ) return false;
if ( early.authToken !== authToken || exchangedAuthToken !== authToken ) return false;
if ( VERIFICATION_GATE_FLAGS.some(flag => early.user[flag]) ) return false;
const age = now - early.fetchedAt;
return age >= 0 && age <= POPUP_WHOAMI_MAX_AGE_MS;
};
/**
* Whether a popup running `action` runs the user-app token exchange.
*
-106
View File
@@ -26,10 +26,7 @@ import {
offersFederatedSignInInPopup,
relationshipIsConsent,
relaysTokenAtBoot,
POPUP_WHOAMI_MAX_AGE_MS,
resolveOpenerOrigin,
reusablePopupUser,
reusableTokenExchange,
runsUserAppTokenExchange,
} from './popupAuth.js';
@@ -318,106 +315,3 @@ describe('the retired opener_origin gate', () => {
expect(popupAuth.trustsOpenerOriginParam).toBeUndefined();
});
});
describe('reusableTokenExchange', () => {
const origin = 'https://app.example';
const early = {
origin,
authToken: 'session-a',
data: { token: 'app-token', app_uid: 'app-1' },
};
it('returns the early result for the same opener and session', () => {
expect(reusableTokenExchange(early, origin, 'session-a')).toBe(early.data);
});
it('exchanges again when no early exchange ran', () => {
expect(reusableTokenExchange(null, origin, 'session-a')).toBeNull();
});
it('exchanges again when the early one returned no token', () => {
// A network failure is null; an HTTP failure is the error body.
for ( const data of [null, { code: 'app_blocked', message: 'no' }, { token: '' }] ) {
expect(reusableTokenExchange({ ...early, data }, origin, 'session-a')).toBeNull();
}
});
it('exchanges again after the user switched accounts', () => {
expect(reusableTokenExchange(early, origin, 'session-b')).toBeNull();
});
it('exchanges again for another opener', () => {
expect(reusableTokenExchange(early, 'https://other.example', 'session-a')).toBeNull();
expect(reusableTokenExchange({ ...early, origin: null }, origin, 'session-a')).toBeNull();
});
it('exchanges again without a session token to match on', () => {
const unkeyed = { ...early, authToken: undefined };
expect(reusableTokenExchange(unkeyed, origin, undefined)).toBeNull();
expect(reusableTokenExchange({ ...early, authToken: null }, origin, null)).toBeNull();
});
});
describe('reusablePopupUser', () => {
const early = {
user: { uuid: 'user-a', username: 'a' },
authToken: 'session-a',
fetchedAt: 1_000,
};
const session = {
userUuid: 'user-a',
authToken: 'session-a',
exchangedAuthToken: 'session-a',
now: 2_000,
};
it('reuses the early read for the same user on a session the exchange accepted', () => {
expect(reusablePopupUser(early, session)).toBe(true);
});
it('reads again when the early read failed', () => {
expect(reusablePopupUser(null, session)).toBe(false);
expect(reusablePopupUser({ ...early, user: {} }, session)).toBe(false);
});
it('reads again after the user switched accounts', () => {
expect(reusablePopupUser(early, { ...session, userUuid: 'user-b' })).toBe(false);
expect(reusablePopupUser(early, {
...session,
authToken: 'session-b',
exchangedAuthToken: 'session-b',
})).toBe(false);
});
it('reads again when the exchange did not accept this session', () => {
// A dead session has to reach the read that signs the user out.
expect(reusablePopupUser(early, { ...session, exchangedAuthToken: null })).toBe(false);
expect(reusablePopupUser(early, { ...session, exchangedAuthToken: 'session-b' })).toBe(false);
});
it('reads again when a verification gate is pending', () => {
for ( const flag of [
'requires_email_confirmation',
'requires_phone_verification',
'requires_card_verification',
'requires_password_change',
] ) {
const gated = { ...early, user: { ...early.user, [flag]: true } };
expect(reusablePopupUser(gated, session)).toBe(false);
}
});
it('reads again once the early read is too old', () => {
const at = (now) => reusablePopupUser(early, { ...session, now });
expect(at(early.fetchedAt + POPUP_WHOAMI_MAX_AGE_MS)).toBe(true);
expect(at(early.fetchedAt + POPUP_WHOAMI_MAX_AGE_MS + 1)).toBe(false);
expect(at(early.fetchedAt - 1)).toBe(false);
});
it('reads again without a session token', () => {
expect(reusablePopupUser(
{ ...early, authToken: undefined },
{ ...session, authToken: undefined, exchangedAuthToken: undefined },
)).toBe(false);
});
});
-22
View File
@@ -52,28 +52,6 @@ export async function waitForPuterReady (page) {
await page.waitForFunction(() => !!window.puter?.apps?.create, null, { timeout: 10_000 });
}
/**
* Creates a second account, the one a first-time visitor is given, and
* returns its token and user. Sign-ups are rate limited, so create one per file.
*
* @param {import('@playwright/test').Browser} browser
* @returns {Promise<{ token: string, user: { uuid: string, username: string } }>}
*/
export async function createVisitorAccount (browser) {
const visitorContext = await browser.newContext({
storageState: { cookies: [], origins: [] },
});
const visitorPage = await visitorContext.newPage();
await visitorPage.goto('/');
await waitForPuterReady(visitorPage);
const account = await visitorPage.evaluate(() => ({
token: localStorage.getItem('auth_token_v2'),
user: JSON.parse(localStorage.getItem('user')),
}));
await visitorContext.close();
return account;
}
async function assertFixtureReachable (fixtureURL) {
let res;
try {
@@ -1,144 +0,0 @@
import { test, expect } from '@playwright/test';
import { createVisitorAccount, waitForPuterReady } from '../helpers/testApp.js';
// A second account: what a first-time visitor is given. Created once for the
// whole file, because sign-ups are rate limited.
let visitor;
const visitorAccount = (browser) => (visitor ??= createVisitorAccount(browser));
/**
* Every tab shares one stored session, so switching accounts in one tab hands
* the others a token for a different account. A tab that kept running would
* show its old account's desktop while its requests acted as the new one.
*/
test.describe('an account switch in another tab', () => {
test('reloads this tab as the new account instead of adopting its token', async ({ browser, context, page }) => {
const other = await visitorAccount(browser);
const switchingTab = page;
const otherTab = await context.newPage();
for ( const tab of [switchingTab, otherTab] ) {
await tab.goto('/');
await waitForPuterReady(tab);
}
const firstUser = await otherTab.evaluate(() => window.user.uuid);
expect(firstUser).not.toBe(other.user.uuid);
// Record what this tab holds once it has handled the new token.
// Session storage outlives the reload; `__beforeSwitch` doesn't.
await otherTab.evaluate(() => {
window.__beforeSwitch = true;
window.addEventListener('storage', (event) => {
if ( event.key !== 'auth_token_v2' || !event.newValue ) return;
sessionStorage.setItem('seenOnSwitch', JSON.stringify({
adopted: window.auth_token === event.newValue,
user: window.user?.uuid,
}));
});
});
await switchingTab.evaluate(
({ token, user }) => window.update_auth_data(token, user),
other,
);
await otherTab.waitForFunction(
(uuid) => !window.__beforeSwitch && window.user?.uuid === uuid,
other.user.uuid,
{ timeout: 60_000 },
);
await waitForPuterReady(otherTab);
expect(
await otherTab.evaluate(() => JSON.parse(sessionStorage.getItem('seenOnSwitch'))),
).toEqual({ adopted: false, user: firstUser });
// Booting renews the token for the same account, which the tab that
// switched picks up without reloading.
await expect.poll(() => switchingTab.evaluate(() => ({
user: window.user?.uuid,
current: window.auth_token === localStorage.getItem('auth_token_v2'),
}))).toEqual({ user: other.user.uuid, current: true });
});
test('reloads this tab only once the session cookie names the new account', async ({ browser, context, page }) => {
const other = await visitorAccount(browser);
const switchingTab = page;
const otherTab = await context.newPage();
for ( const tab of [switchingTab, otherTab] ) {
await tab.goto('/');
await waitForPuterReady(tab);
}
// A tab signs in from the cookie when it boots, so one that reloaded
// while the cookie still named the first account would stay on it.
await context.route('**/session/sync-cookie', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.continue();
});
await otherTab.evaluate(() => {
window.__beforeSwitch = true;
});
await switchingTab.evaluate(
({ token, user }) => window.update_auth_data(token, user),
other,
);
await otherTab.waitForFunction(
(uuid) => !window.__beforeSwitch && window.user?.uuid === uuid,
other.user.uuid,
{ timeout: 30_000 },
);
await waitForPuterReady(otherTab);
await expect.poll(() => switchingTab.evaluate(() => ({
user: window.user?.uuid,
current: window.auth_token === localStorage.getItem('auth_token_v2'),
storedFor: JSON.parse(localStorage.getItem('user')).uuid,
}))).toEqual({ user: other.user.uuid, current: true, storedFor: other.user.uuid });
});
});
test.describe('a token another tab stores during an account switch', () => {
test('does not replace the token of the account being switched to', async ({ browser, context, page }) => {
const other = await visitorAccount(browser);
const switchingTab = page;
const otherTab = await context.newPage();
for ( const tab of [switchingTab, otherTab] ) {
await tab.goto('/');
await waitForPuterReady(tab);
}
const firstToken = await switchingTab.evaluate(() => window.auth_token);
let releaseSync;
const syncHeld = new Promise((resolve) => {
releaseSync = resolve;
});
await context.route('**/session/sync-cookie', async (route) => {
await syncHeld;
await route.continue();
});
const switched = switchingTab.evaluate(
({ token, user }) => window.update_auth_data(token, user),
other,
);
// Booting renews the first account's token while the switch waits.
// A token issued in the same second as the stored one is identical.
await otherTab.waitForTimeout(1100);
await otherTab.reload();
await expect.poll(() => switchingTab.evaluate(
() => localStorage.getItem('auth_token_v2'),
)).not.toBe(firstToken);
releaseSync();
await switched;
expect(await switchingTab.evaluate(() => ({
token: window.auth_token,
saved: window.logged_in_users.find((known) => known.uuid === window.user.uuid).auth_token,
}))).toEqual({ token: other.token, saved: other.token });
});
});
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { FIXTURE_URL, createVisitorAccount, waitForPuterReady } from '../helpers/testApp.js';
import { FIXTURE_URL } from '../helpers/testApp.js';
const FIXTURE = FIXTURE_URL.replace(
'menubar-contextmenu.html',
@@ -178,116 +178,3 @@ test.describe('popup sign-in cannot be driven from a link', () => {
expect(await page.evaluate(() => window.__leaked)).toBe('waiting');
});
});
test.describe('popup sign-in after an account is picked', () => {
// The popup's own `/whoami` read carries `icon_size`. puter.js sends a
// bare one on every `setAuthToken()`, which the popup does not wait on.
const COUNTED = ['/auth/get-user-app-token', '/auth/check-app', '/whoami?icon_size=64'];
/**
* Open a sign-in popup from the fixture, pick the signed-in account, and
* report the API requests the popup sent after the pick. With a `session`
* the page waits on `/login/wait` for it, as an isolated opener does. With
* an `other` account, that one is signed in on the browser too, and picked.
*/
const pickAccount = async (page, popupQuery, session = null, other = null) => {
await page.goto('/');
await page.waitForFunction(() => !!window.puter?.authToken, null, {
timeout: 60_000,
});
if ( other ) {
await waitForPuterReady(page);
await page.evaluate(({ token, user }) => {
const known = JSON.parse(localStorage.getItem('logged_in_users'));
known.push({ ...user, auth_token: token });
localStorage.setItem('logged_in_users', JSON.stringify(known));
}, other);
}
await page.goto(FIXTURE);
await page.locator('body.ready').waitFor({ timeout: 60_000 });
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.evaluate(({ query, relaySession }) => {
if ( relaySession ) {
window.__relayed = fetch(`${puter.APIOrigin}/login/wait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: relaySession }),
}).then(async (response) => (await response.json())?.auth_token);
}
window.__delivered = null;
window.addEventListener('message', (event) => {
if ( event.data?.msg === 'puter.token' ) {
window.__delivered = event.data;
}
});
window.open(
`${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&${query}`,
'signin-pick',
'width=600,height=700',
);
}, { query: popupQuery, relaySession: session }),
]);
const accounts = popup.locator('.auth-dialog [data-screen="accounts"] .auth-account');
await expect(accounts).toHaveCount(other ? 2 : 1, { timeout: 60_000 });
const account = other ? accounts.and(popup.locator(`[data-uuid="${other.user.uuid}"]`)) : accounts;
const requests = [];
popup.on('request', (request) => {
if ( request.method() === 'OPTIONS' ) return;
const { pathname, search } = new URL(request.url());
if ( COUNTED.includes(pathname + search) ) requests.push(pathname + search);
});
await Promise.all([popup.waitForEvent('close'), account.click()]);
return requests;
};
/** The username the API resolves `token` to, asked from the fixture page. */
const usernameFor = (page, token) =>
page.evaluate(async (bearer) => {
const response = await fetch(`${puter.APIOrigin}/whoami`, {
headers: { Authorization: `Bearer ${bearer}` },
});
return response.ok ? (await response.json()).username : null;
}, token);
test('hands the opener a working token after one exchange', async ({ page }) => {
const requests = await pickAccount(page, 'msg_id=92');
await page.waitForFunction(() => !!window.__delivered);
const delivered = await page.evaluate(() => window.__delivered);
expect(delivered.success).toBe(true);
expect(delivered.msg_id).toBe('92');
expect(delivered.app_uid).toMatch(/^app-/);
expect(await usernameFor(page, delivered.token)).toBe(delivered.username);
expect(requests).toEqual(['/auth/get-user-app-token']);
});
test('relays a working token to an isolated opener after one exchange', async ({ page }) => {
const session = '22222222-3333-4444-8555-aaaaaaaaaaaa';
const requests = await pickAccount(
page,
`cross_origin_isolated=true&signin_session=${session}&msg_id=93`,
session,
);
const token = await page.evaluate(() => window.__relayed);
expect(await usernameFor(page, token)).toBeTruthy();
expect(requests).toEqual(['/auth/get-user-app-token']);
});
test('hands over the picked account when it is not the one the popup opened with', async ({ browser, page }) => {
const other = await createVisitorAccount(browser);
const requests = await pickAccount(page, 'msg_id=94', null, other);
await page.waitForFunction(() => !!window.__delivered);
const delivered = await page.evaluate(() => window.__delivered);
expect(delivered.success).toBe(true);
expect(delivered.username).toBe(other.user.username);
expect(await usernameFor(page, delivered.token)).toBe(other.user.username);
// The popup has not read this account yet, so it reads it once.
expect(requests).toEqual(['/auth/get-user-app-token', '/whoami?icon_size=64']);
});
});