fix: open read-only shared files instead of forwarding write_url=undefined (#3667)

Opening a file you hold read-but-not-write access on (a read-only share)
failed silently. For such a file the backend correctly omits write_url from
the /open_item signature, but launchApp appended it to the app iframe URL
unconditionally. URLSearchParams coerces undefined to the string \"undefined\",
so the app received puter.item.write_url=\"undefined\" — truthy, so the editor
believed the file was writable, and an invalid URL, so it broke on open. Only
read-only shares hit this; files you can write carry a real write_url.

Extract the puter.item.* param building into append_signed_item_params and
guard the write_url append so it is only added when present. launchApp.js is
too coupled to UIWindow/jQuery/window globals to unit-test directly, so the
pure helper carries the logic and its own test, matching the helpers/ pattern.

Regression test pins both directions: a read-only signature omits write_url
entirely (no \"undefined\"), and a writable signature still forwards it.
This commit is contained in:
Juan Fernando Castro
2026-08-29 01:42:46 -07:00
committed by GitHub
parent cdfd38bd4b
commit c17613669c
3 changed files with 112 additions and 10 deletions
@@ -0,0 +1,41 @@
/*
* 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/>.
*/
/**
* Append a signed file's `puter.item.*` params onto the launch iframe URL.
*
* @param {URLSearchParams} searchParams the iframe URL's search params
* @param {object} file_signature the /sign or /open_item signature object
* @param {string} itemPath the (privacy-aware) path to advertise to the app
*/
export const append_signed_item_params = (searchParams, file_signature, itemPath) => {
searchParams.append('puter.item.uid', file_signature.uid);
searchParams.append('puter.item.path', itemPath);
searchParams.append('puter.item.name', file_signature.fsentry_name);
searchParams.append('puter.item.read_url', file_signature.read_url);
// Read-only shares carry no write_url; appending undefined stringifies it to "undefined", a truthy invalid URL that stops the editor opening the file.
if ( file_signature.write_url ) {
searchParams.append('puter.item.write_url', file_signature.write_url);
}
searchParams.append('puter.item.metadata_url', file_signature.metadata_url);
searchParams.append('puter.item.size', file_signature.fsentry_size);
searchParams.append('puter.item.accessed', file_signature.fsentry_accessed);
searchParams.append('puter.item.modified', file_signature.fsentry_modified);
searchParams.append('puter.item.created', file_signature.fsentry_created);
};
@@ -0,0 +1,65 @@
/*
* 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, it, expect } from 'vitest';
import { append_signed_item_params } from './appendSignedItemParams.js';
const readOnlySignature = {
uid: 'uid-1',
fsentry_name: 'shared.txt',
read_url: 'https://api.puter.test/file?uid=uid-1&signature=read',
// write_url intentionally absent — the backend strips it for a read-only share.
metadata_url: 'https://api.puter.test/itemMetadata?uid=uid-1&signature=read',
fsentry_size: 12,
fsentry_accessed: 1,
fsentry_modified: 2,
fsentry_created: 3,
path: '/owner/shared.txt',
};
describe('append_signed_item_params', () => {
// Appending an absent write_url stringifies it to a truthy, invalid "undefined" that stops the editor opening; it must be omitted.
it('omits write_url for a read-only share instead of forwarding "undefined"', () => {
const params = new URLSearchParams();
append_signed_item_params(params, readOnlySignature, '/owner/shared.txt');
expect(params.has('puter.item.write_url')).toBe(false);
expect(params.get('puter.item.write_url')).toBeNull();
expect(params.get('puter.item.read_url')).toBe(readOnlySignature.read_url);
});
it('forwards write_url when the share grants write', () => {
const params = new URLSearchParams();
append_signed_item_params(
params,
{ ...readOnlySignature, write_url: 'https://api.puter.test/writeFile?uid=uid-1&signature=write' },
'/owner/shared.txt',
);
expect(params.get('puter.item.write_url')).toBe(
'https://api.puter.test/writeFile?uid=uid-1&signature=write',
);
});
it('advertises the passed path rather than the signature path', () => {
const params = new URLSearchParams();
append_signed_item_params(params, readOnlySignature, '~/shared.txt');
expect(params.get('puter.item.path')).toBe('~/shared.txt');
});
});
+6 -10
View File
@@ -21,6 +21,7 @@ import path from '../lib/path.js';
import { PROCESS_IPC_ATTACHED, PROCESS_RUNNING, PortalProcess, PseudoProcess } from '../definitions.js';
import UIWindow from '../UI/UIWindow.js';
import { starts_hidden } from './startsHidden.js';
import { append_signed_item_params } from './appendSignedItemParams.js';
const normalizePrivateAccessDecision = (privateAccess) => {
if ( !privateAccess || typeof privateAccess !== 'object' ) {
@@ -463,16 +464,11 @@ const launch_app = async (options) => {
}
if ( file_signature ) {
iframe_url.searchParams.append('puter.item.uid', file_signature.uid);
iframe_url.searchParams.append('puter.item.path', options.file_path ? privacy_aware_path(options.file_path) : file_signature.path);
iframe_url.searchParams.append('puter.item.name', file_signature.fsentry_name);
iframe_url.searchParams.append('puter.item.read_url', file_signature.read_url);
iframe_url.searchParams.append('puter.item.write_url', file_signature.write_url);
iframe_url.searchParams.append('puter.item.metadata_url', file_signature.metadata_url);
iframe_url.searchParams.append('puter.item.size', file_signature.fsentry_size);
iframe_url.searchParams.append('puter.item.accessed', file_signature.fsentry_accessed);
iframe_url.searchParams.append('puter.item.modified', file_signature.fsentry_modified);
iframe_url.searchParams.append('puter.item.created', file_signature.fsentry_created);
append_signed_item_params(
iframe_url.searchParams,
file_signature,
options.file_path ? privacy_aware_path(options.file_path) : file_signature.path,
);
}
else if ( options.readURL ) {
iframe_url.searchParams.append('puter.item.name', options.filename);