diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index 2b1b2823e..8766dc05c 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -2596,8 +2596,9 @@ export class FSController extends PuterController { }; } + /** `forOwner` — these events go to the owner, not to the acting user. */ async #toGuiFsEntry(entry: FSEntry): Promise> { - return toLegacyEntry(this.clients.event, entry); + return toLegacyEntry(this.clients.event, entry, { forOwner: true }); } async #emitGuiWriteEvent( diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 4c7372dcd..302925370 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -45,7 +45,6 @@ import { } from '../../util/fileSigning.js'; import { APP_ICON_SIZES, getAppIconCdnUrl } from '../../util/appIcon.js'; import { - expandTildePath, joinChildPath, normalizeAbsolutePath, splitParentAndName, @@ -83,6 +82,7 @@ import { assertAccess, assertCanCreate, assertCanMoveInto, + expandClientPath, getBoolean, getString, loadLegacyAssociatedApps, @@ -702,14 +702,21 @@ export class LegacyFSController extends PuterController { // When `parent` is a path string, use it directly without requiring // the entry to exist — `services.fs.mkdir` honors `create_missing_parents` // and will materialize any missing intermediate directories. - let targetPath = rawPath; + const username = actor.user?.username; + let targetPath = rawPath.startsWith('/') + ? await expandClientPath(this.stores.fsEntry, rawPath, username) + : rawPath; if (body.parent !== undefined && !rawPath.startsWith('/')) { let parentPath: string; if ( typeof body.parent === 'string' && (body.parent.startsWith('/') || body.parent.startsWith('~')) ) { - parentPath = expandTildePath(body.parent, actor.user?.username); + parentPath = await expandClientPath( + this.stores.fsEntry, + body.parent, + username, + ); } else { const parent = await resolveV1Selector( this.stores.fsEntry, @@ -910,9 +917,7 @@ export class LegacyFSController extends PuterController { // Trash, and `null`/`{}` when restoring. See // `src/gui/src/helpers.js` → `window.move_items`. newMetadata: (body.new_metadata ?? undefined) as - | Record - | null - | undefined, + Record | null | undefined, }); const oldPath = source.path; await this.#emitGuiEvent('outer.gui.item.moved', moved, { @@ -1045,9 +1050,12 @@ export class LegacyFSController extends PuterController { legacyCode: 'bad_request', }); - const parentPath = pathPosix.dirname( + const targetPath = await expandClientPath( + this.stores.fsEntry, rawPath.startsWith('/') ? rawPath : `/${rawPath}`, + actor.user?.username, ); + const parentPath = pathPosix.dirname(targetPath); if (parentPath === '/') { throw new HttpError(400, 'Cannot touch in root', { legacyCode: 'bad_request', @@ -1062,7 +1070,7 @@ export class LegacyFSController extends PuterController { ); await this.services.fs.touch(userId, { - path: rawPath, + path: targetPath, setAccessed: getBoolean(body, 'set_accessed_to_now') ?? false, setModified: getBoolean(body, 'set_modified_to_now') ?? false, setCreated: getBoolean(body, 'set_created_to_now') ?? false, @@ -1359,8 +1367,7 @@ export class LegacyFSController extends PuterController { } type SignedOrEmpty = - | (SignedFile & { path?: string }) - | Record; + (SignedFile & { path?: string }) | Record; const result: { signatures: SignedOrEmpty[]; token?: string } = { signatures: [], }; @@ -1983,10 +1990,7 @@ export class LegacyFSController extends PuterController { const subjectRef = body.subject; const appRef = body.app; const mode = (getString(body, 'mode') ?? 'read') as - | 'see' - | 'list' - | 'read' - | 'write'; + 'see' | 'list' | 'read' | 'write'; if (!subjectRef || !appRef) throw new HttpError(400, '`subject` and `app` are required', { legacyCode: 'bad_request', @@ -2114,9 +2118,12 @@ export class LegacyFSController extends PuterController { // silently ignores. Run the entry through `toLegacyEntry` first so // the event payload matches what /stat et al. return, then overlay // per-op extras (e.g. `old_path` for moves). + // `forOwner` — the audience is the owner, who can't read the actor's mask. try { const response = { - ...(await toLegacyEntry(this.clients.event, entry)), + ...(await toLegacyEntry(this.clients.event, entry, { + forOwner: true, + })), ...extra, from_new_service: true, }; @@ -2301,7 +2308,8 @@ export class LegacyFSController extends PuterController { getString(record, 'path') ?? '', pathRefs, ); - const expandedParent = expandTildePath( + const expandedParent = await expandClientPath( + this.stores.fsEntry, parentPath, username, ); @@ -2363,7 +2371,7 @@ export class LegacyFSController extends PuterController { ); } else if (op === 'mkdir') { const { targetPath, writeAclPath } = - this.#resolveBatchMkdirTarget( + await this.#resolveBatchMkdirTarget( record, username, pathRefs, @@ -2411,7 +2419,8 @@ export class LegacyFSController extends PuterController { this.stores.fsEntry, resolveBatchRef(shortcutToRaw, pathRefs), ); - const expandedParent = expandTildePath( + const expandedParent = await expandClientPath( + this.stores.fsEntry, resolveBatchRef(parentPath, pathRefs), username, ); @@ -2715,11 +2724,11 @@ export class LegacyFSController extends PuterController { * `parent`, `{ path, name }` with `path` as the parent, or an absolute `{ * path }`. */ - #resolveBatchMkdirTarget( + async #resolveBatchMkdirTarget( record: Record, username: string | undefined, pathRefs: Map, - ): { targetPath: string; writeAclPath: string } { + ): Promise<{ targetPath: string; writeAclPath: string }> { // Write is checked on the immediate parent: a multi-segment relative // path must not be authorized by a grant on the target itself. Under // `/` there is no parent to check, so the target stands in. @@ -2734,7 +2743,8 @@ export class LegacyFSController extends PuterController { const name = getString(record, 'name'); if (typeof record.parent === 'string' && !name) { - const parentPath = expandTildePath( + const parentPath = await expandClientPath( + this.stores.fsEntry, resolveBatchRef(record.parent, pathRefs), username, ); @@ -2754,7 +2764,8 @@ export class LegacyFSController extends PuterController { } if (name) { - const parentPath = expandTildePath( + const parentPath = await expandClientPath( + this.stores.fsEntry, resolveBatchRef(getString(record, 'path') ?? '', pathRefs), username, ); @@ -2768,7 +2779,11 @@ export class LegacyFSController extends PuterController { }); } const { parentPath, name: derivedName } = splitParentAndName( - expandTildePath(resolveBatchRef(rawPath, pathRefs), username), + await expandClientPath( + this.stores.fsEntry, + resolveBatchRef(rawPath, pathRefs), + username, + ), ); return withAclPath(joinChildPath(parentPath, derivedName)); } diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts index 83e19c390..d8dbeeec4 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.ts @@ -477,11 +477,13 @@ export async function toLegacyEntry( appsById?: Map>; /** Omitted from the response when undefined. */ isShared?: boolean | null; + /** Skip the mask: the mask is the actor's, and only they can read it. */ + forOwner?: boolean; } = {}, ): Promise> { // Someone else's entry is published under its masked path; the owner's // real one, and everything above the share, stays server-side. - const publishedPath = maskEntryPath(entry); + const publishedPath = opts.forOwner ? entry.path : maskEntryPath(entry); const dirname = pathPosix.dirname(publishedPath); const mimeType = fsEntryMimeType(entry); diff --git a/src/backend/controllers/fs/sharedFolderWrites.http.test.ts b/src/backend/controllers/fs/sharedFolderWrites.http.test.ts new file mode 100644 index 000000000..7b9b8a46c --- /dev/null +++ b/src/backend/controllers/fs/sharedFolderWrites.http.test.ts @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + createTestUser, + setupPuterTestEnv, + type PuterTestEnv, +} from '../../testUtil.js'; + +// A recipient addresses the owner's entries by a masked `///` +// path. Routes that create an entry have no row to resolve, so they unmask it +// themselves before the ACL check runs against the result. +describe('writes inside a folder shared with write', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 180_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const post = async (route: string, token: string, body: unknown) => { + const res = await fetch(new URL(route, env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + return { + status: res.status, + json: text ? (JSON.parse(text) as Record) : null, + }; + }; + + const makeUser = () => + createTestUser(env.server, { + username: `sfw${Math.random().toString(36).slice(2, 9)}`, + password: 'puter-test-user-password', + }); + + /** `//SharedDir` with `note.txt`, shared `write` with a recipient. */ + const shareAFolder = async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const dir = await post('/mkdir', owner.token, { + path: `/${owner.username}/SharedDir`, + }); + await post('/touch', owner.token, { + path: `/${owner.username}/SharedDir/note.txt`, + }); + const note = await post('/stat', owner.token, { + path: `/${owner.username}/SharedDir/note.txt`, + }); + await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: dir.json!.uid }], + mode: 'write', + }); + return { + owner, + recipient, + realDir: `/${owner.username}/SharedDir`, + maskedDir: `/${owner.username}/${dir.json!.uid}/SharedDir`, + noteUid: note.json!.uid as string, + }; + }; + + const ownerListing = async (s: { owner: { token: string; username: string } }) => { + const res = await post('/readdir', s.owner.token, { + path: `/${s.owner.username}/SharedDir`, + }); + return (res.json as unknown as Array<{ name: string }>).map( + (entry) => entry.name, + ); + }; + + // `{parent, path}` is the shape puter.js sends — the GUI's "New Folder". + it('mkdir accepts a masked parent', async () => { + const s = await shareAFolder(); + + const made = await post('/mkdir', s.recipient.token, { + parent: s.maskedDir, + path: 'New Folder', + }); + + expect(made.status).toBe(200); + expect(made.json).toMatchObject({ + name: 'New Folder', + is_dir: true, + // Anchored to the share root the request came through, so the + // recipient can navigate into what they just made. + path: `${s.maskedDir}/New Folder`, + }); + expect(await ownerListing(s)).toContain('New Folder'); + }, 180_000); + + it('mkdir accepts a masked absolute path', async () => { + const s = await shareAFolder(); + + const made = await post('/mkdir', s.recipient.token, { + path: `${s.maskedDir}/Reports`, + }); + + expect(made.status).toBe(200); + expect(await ownerListing(s)).toContain('Reports'); + }, 180_000); + + it('touch accepts a masked path', async () => { + const s = await shareAFolder(); + + const touched = await post('/touch', s.recipient.token, { + path: `${s.maskedDir}/from-recipient.txt`, + }); + + expect(touched.status).toBe(200); + expect(await ownerListing(s)).toContain('from-recipient.txt'); + }, 180_000); + + // Addressed to the owner, so it carries the path the owner can resolve. + it('publishes the owner’s real path on events about their entries', async () => { + const s = await shareAFolder(); + const seen: Array> = []; + const emit = env.server.clients.event.emit.bind( + env.server.clients.event, + ); + vi.spyOn(env.server.clients.event, 'emit').mockImplementation( + ((name: string, payload: Record, meta: unknown) => { + if (name.startsWith('outer.gui.item.')) { + seen.push(payload); + } + return emit( + name as never, + payload as never, + meta as never, + ); + }) as never, + ); + + const moved = await post('/move', s.recipient.token, { + source: s.noteUid, + destination: s.maskedDir, + new_name: 'renamed.txt', + }); + expect(moved.status).toBe(200); + + const ownerId = (await env.server.stores.user.getByUsername( + s.owner.username, + ))!.id; + const event = seen.find( + (payload) => + (payload.response as { uid?: string })?.uid === s.noteUid, + ); + expect(event?.user_id_list).toEqual([ownerId]); + expect(event?.response).toMatchObject({ + path: `${s.realDir}/renamed.txt`, + dirpath: s.realDir, + }); + + // The recipient's own response stays masked. + expect( + (moved.json!.moved as { path: string }).path, + ).toBe(`${s.maskedDir}/renamed.txt`); + }, 180_000); +}); diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index e37cf72fd..0fe064980 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -43,6 +43,7 @@ import list_all_shared from '../../helpers/listAllShared.js'; import { can_restructure, can_share, remember_shared_root, remember_shared_roots } from '../../helpers/sharedAccess.js'; import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from_paths } from '../../helpers/sharePaths.js'; import resolve_shared_item from '../../helpers/resolveSharedItem.js'; +import parse_item_metadata from '../../helpers/parseItemMetadata.js'; const { html_encode, SelectionArea } = window; @@ -2615,15 +2616,7 @@ const TabFiles = { async renderItem (file, iconResult = null) { // For trashed items, use original_name from metadata if available const item_id = window.global_element_id++; - // metadata is a client-writable, untrusted string stored verbatim, so - // it may be '', undefined, or malformed. Guard the parse (as itemIcon.js - // does) — an unguarded throw here aborts the whole directory render. - let metadata = {}; - try { - if ( file.metadata ) metadata = JSON.parse(file.metadata) || {}; - } catch { - metadata = {}; - } + const metadata = parse_item_metadata(file.metadata); const displayName = metadata.original_name || file.name; let website_url = window.determine_website_url(file.path); // Normalize is_shortcut to 0/1. Directory listings return it as a number, diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index a3c0ea439..e4e56ea9b 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -47,6 +47,7 @@ import resolve_shared_item from '../helpers/resolveSharedItem.js'; import { applyToastMark, createNotificationFeed } from '../helpers/notificationFeed.js'; import { notificationTarget } from './Dashboard/notificationCenter.js'; import apply_item_added_to_containers from '../helpers/applyItemAddedToContainers.js'; +import parse_item_metadata from '../helpers/parseItemMetadata.js'; import UIWindowSearch from './UIWindowSearch.js'; async function UIDesktop (options) { @@ -442,7 +443,8 @@ async function UIDesktop (options) { } let dest_path = path.dirname(fsentry.path); - let metadata = fsentry.metadata; + // A JSON string on the wire; trash/restore reads its fields. + const metadata = parse_item_metadata(fsentry.metadata); // update all shortcut_to_path $(`.item[data-shortcut_to_path="${html_encode(resp.old_path)}" i]`).attr('data-shortcut_to_path', html_encode(fsentry.path)); @@ -493,8 +495,8 @@ async function UIDesktop (options) { $(`.item[data-uid=${fsentry.overwritten_uid}]`).removeItems(); } - // if this is trash, get original name from item metadata - fsentry.name = (metadata && metadata.original_name) ? metadata.original_name : fsentry.name; + // Trashing renames the entry to its uid; the real name is in metadata. + fsentry.name = metadata.original_name ?? fsentry.name; // create new item on matching containers UIItem({ @@ -503,7 +505,7 @@ async function UIDesktop (options) { uid: fsentry.uid, path: fsentry.path, icon: await item_icon(fsentry), - name: (dest_path === window.trash_path) ? metadata.original_name : fsentry.name, + name: fsentry.name, is_dir: fsentry.is_dir, size: fsentry.size, type: fsentry.type, @@ -513,7 +515,8 @@ async function UIDesktop (options) { shortcut_to: fsentry.shortcut_to, shortcut_to_path: fsentry.shortcut_to_path, // has_website: $(el_item).attr('data-has_website') === '1', - metadata: JSON.stringify(fsentry.metadata) ?? '', + // Raw, as every other renderer stores it; re-encoding breaks restore. + metadata: fsentry.metadata ?? '', }); if ( fsentry.parent_dirs_created && fsentry.parent_dirs_created.length > 0 ) { diff --git a/src/gui/src/helpers/parseItemMetadata.js b/src/gui/src/helpers/parseItemMetadata.js new file mode 100644 index 000000000..1f42f4009 --- /dev/null +++ b/src/gui/src/helpers/parseItemMetadata.js @@ -0,0 +1,37 @@ +/* + * 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 . + */ + +/** + * An fsentry's `metadata` as an object. It arrives as a client-writable JSON + * string, so anything unparseable reads as `{}` rather than throwing mid-render. + * + * @param {unknown} metadata + * @returns {Record} + */ +const parse_item_metadata = (metadata) => { + if ( metadata && typeof metadata === 'object' ) return metadata; + if ( typeof metadata !== 'string' || metadata === '' ) return {}; + try { + return JSON.parse(metadata) || {}; + } catch { + return {}; + } +}; + +export default parse_item_metadata; diff --git a/src/gui/src/helpers/parseItemMetadata.test.js b/src/gui/src/helpers/parseItemMetadata.test.js new file mode 100644 index 000000000..e8b496bff --- /dev/null +++ b/src/gui/src/helpers/parseItemMetadata.test.js @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import parse_item_metadata from './parseItemMetadata.js'; + +describe('parse_item_metadata', () => { + it('parses the JSON string the wire carries', () => { + expect(parse_item_metadata('{"original_name":"a.txt","trashed_ts":1}')) + .toEqual({ original_name: 'a.txt', trashed_ts: 1 }); + }); + + it('reads an absent or unparseable value as empty', () => { + for ( const value of [undefined, null, '', 'null', '{oops', 7] ) { + expect(parse_item_metadata(value)).toEqual({}); + } + }); + + it('passes an already-parsed object through', () => { + const parsed = { original_name: 'a.txt' }; + expect(parse_item_metadata(parsed)).toBe(parsed); + }); +}); diff --git a/src/gui/src/helpers/refreshItemContainer.js b/src/gui/src/helpers/refreshItemContainer.js index 668dcbabe..a64dc3710 100644 --- a/src/gui/src/helpers/refreshItemContainer.js +++ b/src/gui/src/helpers/refreshItemContainer.js @@ -21,6 +21,7 @@ import path from '../lib/path.js'; import UIItem from '../UI/UIItem.js'; import item_icon from './itemIcon.js'; import list_all_shared from './listAllShared.js'; +import parse_item_metadata from './parseItemMetadata.js'; import { remember_shared_roots } from './sharedAccess.js'; const refresh_item_container = function (el_item_container, options) { @@ -211,22 +212,13 @@ const refresh_item_container = function (el_item_container, options) { visible = 'hidden'; } - // metadata - let metadata; - if ( fsentry.metadata !== '' ) { - try { - metadata = JSON.parse(fsentry.metadata); - } - catch (e) { - // Ignored - } - } + const metadata = parse_item_metadata(fsentry.metadata); const item_path = fsentry.path ?? path.join($(el_window).attr('data-path'), fsentry.name); // render any item but Trash/AppData if ( item_path !== window.trash_path && item_path !== window.appdata_path ) { // if this is trash, get original name from item metadata - fsentry.name = (metadata && metadata.original_name !== undefined) ? metadata.original_name : fsentry.name; + fsentry.name = metadata.original_name ?? fsentry.name; const position = window.desktop_item_positions[fsentry.uid] ?? undefined; UIItem({ appendTo: el_item_container, @@ -235,7 +227,7 @@ const refresh_item_container = function (el_item_container, options) { associated_app_name: fsentry.associated_app?.name, path: item_path, icon: await item_icon(fsentry), - name: (metadata && metadata.original_name !== undefined) ? metadata.original_name : fsentry.name, + name: fsentry.name, is_dir: fsentry.is_dir, multiselectable: !is_openFileDialog, has_website: fsentry.has_website,