mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-11 22:32:26 +00:00
fix: let a share recipient create entries in a folder shared with them
A recipient addresses the owner's entries by a masked `/<owner>/<uuid>/<name>` path. Routes that resolve an existing row unmask it on the way in, but the ones that create a new entry had nothing to resolve and ran the ACL check against the mask itself, which names no row — so mkdir, touch and the batch write/mkdir/ shortcut ops answered 404 `subject_does_not_exist` inside any shared folder. They now expand client paths the same way every other legacy route does. The GUI events carried the same confusion the other way: the payload was masked for whoever made the request but addressed to the entry's owner, who cannot resolve another user's mask. The owner's client dropped the row by uid and then failed to re-add it under a path it had never heard of, so anything a recipient touched vanished from the owner's open window until a refresh. Those events now publish the owner's real path; responses to the actor stay masked. Also fixes the desktop's `item.moved` handler reading `metadata` as an object when the wire carries a JSON string: the name of a trashed item came out empty (trashing renames the entry to its uid), and re-encoding the string left the restore path parsing a string instead of an object. The parse the dashboard and the explorer already open-coded is now one helper.
This commit is contained in:
1 parent
8862420594
commit
ec4d3b31cf
9 files changed
+300
-51
No files matched your search
@@ -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<Record<string, unknown>> {
|
||||
return toLegacyEntry(this.clients.event, entry);
|
||||
return toLegacyEntry(this.clients.event, entry, { forOwner: true });
|
||||
}
|
||||
|
||||
async #emitGuiWriteEvent(
|
||||
|
||||
@@ -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<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | 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<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
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<string, unknown>,
|
||||
username: string | undefined,
|
||||
pathRefs: Map<string, string>,
|
||||
): { 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));
|
||||
}
|
||||
|
||||
@@ -477,11 +477,13 @@ export async function toLegacyEntry(
|
||||
appsById?: Map<number, Record<string, unknown>>;
|
||||
/** 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<Record<string, unknown>> {
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 `/<owner>/<uuid>/<name>`
|
||||
// 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<string, unknown>) : null,
|
||||
};
|
||||
};
|
||||
|
||||
const makeUser = () =>
|
||||
createTestUser(env.server, {
|
||||
username: `sfw${Math.random().toString(36).slice(2, 9)}`,
|
||||
password: 'puter-test-user-password',
|
||||
});
|
||||
|
||||
/** `/<owner>/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<Record<string, unknown>> = [];
|
||||
const emit = env.server.clients.event.emit.bind(
|
||||
env.server.clients.event,
|
||||
);
|
||||
vi.spyOn(env.server.clients.event, 'emit').mockImplementation(
|
||||
((name: string, payload: Record<string, unknown>, 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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<string, unknown>}
|
||||
*/
|
||||
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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user