diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index d653f4831..87f7d0db4 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -29,6 +29,7 @@ import { assertNormalized, isOwnersTrash, } from '../../services/fs/resolveNode.js'; +import { SharePathMasker } from './legacyFsHelpers.js'; import type { PreparedBatchWrite, UploadedBatchWriteItem, @@ -193,7 +194,9 @@ export class FSController extends PuterController { }, 'emitStartWriteDirectoryEvents'); } res.json( - this.#withoutStorageInternals(this.#withClientFsEntry(response)), + this.#withoutStorageInternals( + await this.#withClientFsEntry(response), + ), ); } @@ -296,8 +299,12 @@ export class FSController extends PuterController { }, 'emitStartBatchWriteDirectoryEvents'); } res.json( - responses.map((r) => - this.#withoutStorageInternals(this.#withClientFsEntry(r)), + await Promise.all( + responses.map(async (r) => + this.#withoutStorageInternals( + await this.#withClientFsEntry(r), + ), + ), ), ); } @@ -331,7 +338,7 @@ export class FSController extends PuterController { requestBody.guiMetadata, ); res.json( - this.#withRequiredClientFsEntry({ + await this.#withRequiredClientFsEntry({ ...response, fsEntry: writeResponse.fsEntry, }), @@ -382,7 +389,9 @@ export class FSController extends PuterController { }, ); res.json( - updatedResponse.map((r) => this.#withRequiredClientFsEntry(r)), + await Promise.all( + updatedResponse.map((r) => this.#withRequiredClientFsEntry(r)), + ), ); } @@ -473,7 +482,7 @@ export class FSController extends PuterController { response, requestBody.guiMetadata, ); - res.json(this.#withRequiredClientFsEntry(updatedResponse)); + res.json(await this.#withRequiredClientFsEntry(updatedResponse)); } @Post('/batchWrite', { @@ -821,7 +830,11 @@ export class FSController extends PuterController { }, ); res.json( - updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)), + await Promise.all( + updatedResponses.map((r) => + this.#withRequiredClientFsEntry(r), + ), + ), ); return; } @@ -943,7 +956,9 @@ export class FSController extends PuterController { }, ); res.json( - updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)), + await Promise.all( + updatedResponses.map((r) => this.#withRequiredClientFsEntry(r)), + ), ); } @@ -971,7 +986,7 @@ export class FSController extends PuterController { await this.services.suggestedApps.getSuggestedApps(entry); res.json({ - ...this.#toClientEntry(entry), + ...(await this.#toClientEntry(entry)), ...(subtreeSize !== undefined ? { size: subtreeSize } : {}), }); } @@ -984,7 +999,7 @@ export class FSController extends PuterController { * (with public folders enabled) any authenticated user. The legacy read * path already curates its output; this does the same for the v2 routes. */ - #toClientEntry(entry: FSEntry): ClientFSEntry { + async #toClientEntry(entry: FSEntry): Promise { // Allowlist, not a denylist: a denylist silently ships every column // added to `fsentries` later. Omits the numeric primary keys (`id`, // `parentId`, `associatedAppId`), the storage columns, the owning @@ -997,7 +1012,9 @@ export class FSController extends PuterController { uuid: entry.uuid, uid: entry.uid ?? entry.uuid, parentUid: entry.parentUid ?? null, - path: entry.path, + path: await SharePathMasker.forRequest(this.services.share).mask( + entry.path, + ), name: entry.name, isDir: entry.isDir, isShortcut: entry.isShortcut, @@ -1028,13 +1045,13 @@ export class FSController extends PuterController { * The return type is the sanitized counterpart, so a caller cannot keep * treating the result as though it still held a full `FSEntry`. */ - #withClientFsEntry( + async #withClientFsEntry( response: T, - ): Omit & { fsEntry?: ClientFSEntry } { + ): Promise & { fsEntry?: ClientFSEntry }> { const { fsEntry, ...rest } = response; return { ...rest, - ...(fsEntry ? { fsEntry: this.#toClientEntry(fsEntry) } : {}), + ...(fsEntry ? { fsEntry: await this.#toClientEntry(fsEntry) } : {}), }; } @@ -1051,12 +1068,12 @@ export class FSController extends PuterController { } /** Same, for the responses whose `fsEntry` is always present. */ - #withRequiredClientFsEntry( + async #withRequiredClientFsEntry( response: T, - ): Omit & { fsEntry: ClientFSEntry } { + ): Promise & { fsEntry: ClientFSEntry }> { return { ...response, - fsEntry: this.#toClientEntry(response.fsEntry), + fsEntry: await this.#toClientEntry(response.fsEntry), }; } @@ -1246,7 +1263,7 @@ export class FSController extends PuterController { ); return Promise.all( entries.map(async (entry) => ({ - ...this.#toClientEntry(entry), + ...(await this.#toClientEntry(entry)), // Fields the client cannot derive on its own. type: fsEntryMimeType(entry), thumbnail: await signEntryThumbnail( @@ -1409,7 +1426,7 @@ export class FSController extends PuterController { ) ?? false, }); this.#emitGuiItemAdded(entry); - res.json(this.#toClientEntry(entry)); + res.json(await this.#toClientEntry(entry)); } @Post('/touch', { @@ -1449,7 +1466,7 @@ export class FSController extends PuterController { createMissingParents: this.#toBoolean(body.create_missing_parents) ?? false, }); - res.json(this.#toClientEntry(entry)); + res.json(await this.#toClientEntry(entry)); } @Post('/rename', { @@ -1472,7 +1489,7 @@ export class FSController extends PuterController { const userId = this.#getActorUserId(req); const renamed = await this.services.fs.rename(userId, entry, newName); this.#emitGuiItemUpdated(renamed); - res.json(this.#toClientEntry(renamed)); + res.json(await this.#toClientEntry(renamed)); } @Post('/delete', { @@ -1528,7 +1545,7 @@ export class FSController extends PuterController { this.#toBoolean(body.dedupe_name ?? body.change_name) ?? false, }); this.#emitGuiItemMoved(source, moved); - res.json(this.#toClientEntry(moved)); + res.json(await this.#toClientEntry(moved)); } @Post('/copy', { @@ -1565,7 +1582,7 @@ export class FSController extends PuterController { : {}), }); this.#emitGuiItemAdded(copy); - res.json(this.#toClientEntry(copy)); + res.json(await this.#toClientEntry(copy)); } @Post('/mkshortcut', { @@ -1598,7 +1615,7 @@ export class FSController extends PuterController { dedupeName: this.#toBoolean(body.dedupe_name) ?? true, }); this.#emitGuiItemAdded(shortcut); - res.json(this.#toClientEntry(shortcut)); + res.json(await this.#toClientEntry(shortcut)); } // -- Read-side helpers ----------------------------------------------- @@ -1628,7 +1645,11 @@ export class FSController extends PuterController { const ref = { path: rawPath !== undefined - ? mod.expandTildePath(rawPath, username) + ? await mod.expandUserPath( + this.stores.fsEntry, + rawPath, + username, + ) : undefined, uid: typeof source.uid === 'string' diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index 4cfe78307..8308ff7a5 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -1269,6 +1269,152 @@ describe('LegacyFSController.move', () => { }); }); +// ── shared paths ──────────────────────────────────────────────────── + +describe('LegacyFSController — shared paths', () => { + const setupShare = async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const ownerName = owner.actor.user!.username!; + const sharedPath = `/${ownerName}/Documents/Contents`; + + await withActor(owner.actor, () => + controller.mkdir( + makeReq({ body: { path: sharedPath }, actor: owner.actor }), + makeRes().res, + ), + ); + await withActor(owner.actor, () => + controller.touch( + makeReq({ + body: { path: `${sharedPath}/note.txt` }, + actor: owner.actor, + }), + makeRes().res, + ), + ); + const shared = (await server.stores.fsEntry.getEntryByPath(sharedPath))!; + await server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: shared.path, + resolveAncestors: () => + server.services.fs.getAncestorChain(shared.path), + }, + 'write', + ); + await server.services.share.share(owner.actor, { + path: shared.path, + recipient: { username: holder.actor.user!.username }, + mode: 'write', + } as never); + + return { owner, holder, ownerName, shared }; + }; + + it('hides the owner’s real path from the recipient', async () => { + const { holder, ownerName, shared } = await setupShare(); + + const { res, captured } = makeRes(); + await withActor(holder.actor, () => + controller.stat( + makeReq({ + body: { path: `~/share/${shared.uuid}/note.txt` }, + actor: holder.actor, + }), + res, + ), + ); + + const body = captured.body as { path: string; dirname: string }; + expect(body.path).toBe(`~/share/${shared.uuid}/note.txt`); + expect(body.dirname).toBe(`~/share/${shared.uuid}`); + expect(body.dirpath).toBe(`~/share/${shared.uuid}`); + // The owner is still named — it is where they keep the file, and what + // else sits beside it, that the recipient has no business seeing. + expect(body.path).not.toContain(ownerName); + }); + + it('masks every child of a shared folder in a listing', async () => { + const { holder, shared } = await setupShare(); + + const { res, captured } = makeRes(); + await withActor(holder.actor, () => + controller.readdir( + makeReq({ + body: { path: `~/share/${shared.uuid}` }, + actor: holder.actor, + }), + res, + ), + ); + + const items = captured.body as Array<{ path: string }>; + expect(items).toHaveLength(1); + expect(items[0]!.path).toBe(`~/share/${shared.uuid}/note.txt`); + expect(items[0]!.dirname).toBe(`~/share/${shared.uuid}`); + }); + + it('accepts a share path for a write, and masks what it returns', async () => { + const { holder, ownerName, shared } = await setupShare(); + + const { res, captured } = makeRes(); + await withActor(holder.actor, () => + controller.mkdir( + makeReq({ + body: { path: `~/share/${shared.uuid}/sub` }, + actor: holder.actor, + }), + res, + ), + ); + + const body = captured.body as { path: string }; + expect(body.path).toBe(`~/share/${shared.uuid}/sub`); + expect(body.path).not.toContain(ownerName); + expect( + await server.stores.fsEntry.getEntryByPath(`${shared.path}/sub`), + ).not.toBeNull(); + }); + + it('leaves the actor’s own paths alone', async () => { + const { holder } = await setupShare(); + const holderName = holder.actor.user!.username!; + + const { res, captured } = makeRes(); + await withActor(holder.actor, () => + controller.stat( + makeReq({ + body: { path: `/${holderName}/Documents` }, + actor: holder.actor, + }), + res, + ), + ); + + const body = captured.body as { path: string }; + expect(body.path).toBe(`/${holderName}/Documents`); + }); + + it('refuses a share path for an entry not shared with the caller', async () => { + const { shared } = await setupShare(); + const stranger = await makeUser(); + + await expect( + withActor(stranger.actor, () => + controller.stat( + makeReq({ + body: { path: `~/share/${shared.uuid}/note.txt` }, + actor: stranger.actor, + }), + makeRes().res, + ), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + }); +}); + // ── search ────────────────────────────────────────────────────────── describe('LegacyFSController.search', () => { diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 966dbb31f..f23cdbfe7 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -70,6 +70,8 @@ import { assertAccess, assertCanCreate, assertCanMoveInto, + expandUserPath, + SharePathMasker, getBoolean, getString, loadLegacyAssociatedApps, @@ -444,6 +446,7 @@ export class LegacyFSController extends PuterController { ]); const shaped = await toLegacyEntry(this.clients.event, entry, { + masker: this.#masker(), fsEntryStore: this.stores.fsEntry, userStore: this.stores.user as unknown as { getById: ( @@ -506,6 +509,8 @@ export class LegacyFSController extends PuterController { const shaped = await Promise.all( rootChildren.map((c) => toLegacyEntry(this.clients.event, c, { + masker: this.#masker(), + userStore: this.#legacyUserStore(), appsById: rootAppsById, }), ), @@ -587,7 +592,11 @@ export class LegacyFSController extends PuterController { const shaped = await Promise.all( children.map((c) => - toLegacyEntry(this.clients.event, c, { appsById }), + toLegacyEntry(this.clients.event, c, { + masker: this.#masker(), + userStore: this.#legacyUserStore(), + appsById, + }), ), ); @@ -620,14 +629,20 @@ 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; + let targetPath = await expandUserPath( + this.stores.fsEntry, + rawPath, + actor.user?.username, + ); if (body.parent !== undefined && !rawPath.startsWith('/')) { let parentPath: string; if ( typeof body.parent === 'string' && (body.parent.startsWith('/') || body.parent.startsWith('~')) ) { - parentPath = this.#expandTilde( + parentPath = await expandUserPath( + this.stores.fsEntry, + body.parent, actor.user?.username, ); @@ -683,7 +698,11 @@ export class LegacyFSController extends PuterController { }); await this.#emitGuiEvent('outer.gui.item.added', entry); - res.json(await toLegacyEntry(this.clients.event, entry)); + res.json( + await toLegacyEntry(this.clients.event, entry, { + masker: this.#masker(), + }), + ); }; copy = async (req: Request, res: Response): Promise => { @@ -750,6 +769,7 @@ export class LegacyFSController extends PuterController { // Legacy response shape: `[{copied: fsentry, overwritten?}]`. // Array is historical — originally supported bulk copy. const legacyEntryOpts = { + masker: this.#masker(), fsEntryStore: this.stores.fsEntry, userStore: this.stores.user as unknown as { getById: ( @@ -848,6 +868,7 @@ export class LegacyFSController extends PuterController { // Legacy response shape: `{moved: fsentry, old_path, overwritten?}`. const legacyEntryOpts = { + masker: this.#masker(), fsEntryStore: this.stores.fsEntry, userStore: this.stores.user as unknown as { getById: ( @@ -902,7 +923,9 @@ export class LegacyFSController extends PuterController { descendants_only: descendantsOnly, }); removedEntries.push( - await toLegacyEntry(this.clients.event, entry), + await toLegacyEntry(this.clients.event, entry, { + masker: this.#masker(), + }), ); } res.json(removedEntries); @@ -950,7 +973,11 @@ export class LegacyFSController extends PuterController { const userId = this.#getActorUserId(req); const renamed = await this.services.fs.rename(userId, entry, newName); await this.#emitGuiEvent('outer.gui.item.updated', renamed); - res.json(await toLegacyEntry(this.clients.event, renamed)); + res.json( + await toLegacyEntry(this.clients.event, renamed, { + masker: this.#masker(), + }), + ); }; touch = async (req: Request, res: Response): Promise => { @@ -958,11 +985,16 @@ export class LegacyFSController extends PuterController { const userId = this.#getActorUserId(req); const body = asRecord(req.body); - const rawPath = getString(body, 'path'); - if (!rawPath) + const requestedPath = getString(body, 'path'); + if (!requestedPath) throw new HttpError(400, '`path` is required', { legacyCode: 'bad_request', }); + const rawPath = await expandUserPath( + this.stores.fsEntry, + requestedPath, + actor.user?.username, + ); const parentPath = pathPosix.dirname( rawPath.startsWith('/') ? rawPath : `/${rawPath}`, @@ -1126,7 +1158,11 @@ export class LegacyFSController extends PuterController { pathScope, ); const shaped = await Promise.all( - results.map((r) => toLegacyEntry(this.clients.event, r)), + results.map((r) => + toLegacyEntry(this.clients.event, r, { + masker: this.#masker(), + }), + ), ); res.json(shaped); }; @@ -1857,7 +1893,11 @@ export class LegacyFSController extends PuterController { path: rootPath, createMissingParents: true, }); - res.json(await toLegacyEntry(this.clients.event, entry)); + res.json( + await toLegacyEntry(this.clients.event, entry, { + masker: this.#masker(), + }), + ); }; /** @@ -1992,7 +2032,9 @@ export class LegacyFSController extends PuterController { // per-op extras (e.g. `old_path` for moves). try { const response = { - ...(await toLegacyEntry(this.clients.event, entry)), + ...(await toLegacyEntry(this.clients.event, entry, { + masker: this.#masker(), + })), ...extra, from_new_service: true, }; @@ -2163,7 +2205,9 @@ export class LegacyFSController extends PuterController { }); } const parentPath = getString(record, 'path') ?? ''; - const expandedParent = this.#expandTilde( + const expandedParent = await expandUserPath( + this.stores.fsEntry, + parentPath, username, ); @@ -2219,6 +2263,7 @@ export class LegacyFSController extends PuterController { shaped = await toLegacyEntry( this.clients.event, response.fsEntry, + { masker: this.#masker() }, ); } else if (op === 'mkdir') { const parentPath = getString(record, 'path') ?? ''; @@ -2228,7 +2273,9 @@ export class LegacyFSController extends PuterController { legacyCode: 'bad_request', }); } - const expandedParent = this.#expandTilde( + const expandedParent = await expandUserPath( + this.stores.fsEntry, + parentPath, username, ); @@ -2258,7 +2305,9 @@ export class LegacyFSController extends PuterController { ) ?? false, }); await this.#emitGuiEvent('outer.gui.item.added', entry); - shaped = await toLegacyEntry(this.clients.event, entry); + shaped = await toLegacyEntry(this.clients.event, entry, { + masker: this.#masker(), + }); } else if (op === 'shortcut') { const parentPath = getString(record, 'path') ?? ''; const name = getString(record, 'name'); @@ -2281,7 +2330,9 @@ export class LegacyFSController extends PuterController { this.stores.fsEntry, { uid: shortcutToUid }, ); - const expandedParent = this.#expandTilde( + const expandedParent = await expandUserPath( + this.stores.fsEntry, + parentPath, username, ); @@ -2310,7 +2361,9 @@ export class LegacyFSController extends PuterController { dedupeName: getBoolean(record, 'dedupe_name') ?? true, }); await this.#emitGuiEvent('outer.gui.item.added', link); - shaped = await toLegacyEntry(this.clients.event, link); + shaped = await toLegacyEntry(this.clients.event, link, { + masker: this.#masker(), + }); } else if (op === 'move') { const source = await resolveV1Selector( this.stores.fsEntry, @@ -2344,7 +2397,9 @@ export class LegacyFSController extends PuterController { await this.#emitGuiEvent('outer.gui.item.moved', moved, { old_path: source.path, }); - shaped = await toLegacyEntry(this.clients.event, moved); + shaped = await toLegacyEntry(this.clients.event, moved, { + masker: this.#masker(), + }); } else if (op === 'delete') { const entry = await resolveV1Selector( this.stores.fsEntry, @@ -2515,14 +2570,18 @@ export class LegacyFSController extends PuterController { return []; } - #expandTilde(path: string, username: string | undefined): string { - if (!path) return path; - if (path !== '~' && !path.startsWith('~/')) return path; - if (!username) - throw new HttpError(400, 'Unable to resolve home path', { - legacyCode: 'bad_request', - }); - return `/${username}${path.slice(1)}`; + /** + * A listing carries `owner` so a client can still tell whose an entry is: a + * masked path no longer says, and addressing the owner's Trash needs it. + */ + #legacyUserStore() { + return this.stores.user as unknown as { + getById: (id: number) => Promise | null>; + }; + } + + #masker(): SharePathMasker { + return SharePathMasker.forRequest(this.services.share); } #serializeBatchError(err: unknown): Record { diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts index 6aa37324c..c99c0a473 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.ts @@ -32,8 +32,10 @@ import { normalizeAbsolutePath, isOwnersTrash, joinChildPath, - expandTildePath, + expandUserPath, } from '../../services/fs/resolveNode.js'; +import { maskUnder } from '../../services/fs/sharePaths.js'; +import type { ShareService } from '../../services/share/ShareService.js'; import { NON_OWNER_SIGNATURE_TTL_SECONDS, signFile, @@ -108,7 +110,7 @@ export async function resolveV1Selector( if (typeof raw === 'string') { const isPath = raw.startsWith('/') || raw.startsWith('~'); const ref = isPath - ? { path: expandTildePath(raw, username) } + ? { path: await expandUserPath(fsEntryStore, raw, username) } : { uid: raw }; const entry = await resolveNode(fsEntryStore, ref, { required: true }); if (!entry) @@ -140,7 +142,7 @@ export async function resolveV1Selector( const ref = { path: rawPath !== undefined - ? expandTildePath(rawPath, username) + ? await expandUserPath(fsEntryStore, rawPath, username) : undefined, uid: typeof record.uid === 'string' @@ -262,6 +264,67 @@ export async function assertCanCreate( await assertAccess(aclService, fsService, actor, parentForCheck, 'write'); } +// -- Share path masking ---------------------------------------------- + +/** + * Rewrites outgoing paths so a recipient sees `~/share//…` rather + * than where the owner keeps the file. Memoized per request — a readdir shapes + * every child through one instance. + */ +export class SharePathMasker { + #roots = new Map(); + + private constructor( + private readonly actor: Actor | undefined, + private readonly shareService: ShareService, + ) {} + + /** The masker for the current request, creating it on first use. */ + static forRequest(shareService: ShareService): SharePathMasker { + const cached = Context.get(MASKER_CONTEXT_KEY); + if (cached instanceof SharePathMasker) return cached; + const masker = new SharePathMasker(Context.get('actor'), shareService); + try { + Context.set(MASKER_CONTEXT_KEY, masker); + } catch { + // Outside a request scope (tests, internal calls) — no memo. + } + return masker; + } + + /** + * `path` as the actor should see it. Their own entries pass through + * untouched, so only paths reached through a share are ever rewritten. + */ + async mask(path: string): Promise { + const username = this.actor?.user?.username; + if (!username || typeof path !== 'string') return path; + if (path === `/${username}` || path.startsWith(`/${username}/`)) { + return path; + } + + const root = await this.#rootFor(path); + if (!root) return path; + return maskUnder(path, root.path, root.uid) ?? path; + } + + async #rootFor(path: string) { + // Keyed by parent: siblings in a listing share one lookup. + const key = pathPosix.dirname(path); + if (!this.#roots.has(key)) { + this.#roots.set( + key, + this.actor + ? await this.shareService.findShareRoot(this.actor, path) + : null, + ); + } + return this.#roots.get(key) ?? null; + } +} + +const MASKER_CONTEXT_KEY = 'fs.sharePathMasker'; + /** `write` on the destination parent, unless it is the entry's own Trash. */ export async function assertCanMoveInto( aclService: ACLService, @@ -429,9 +492,13 @@ export async function toLegacyEntry( getById: (id: number) => Promise | null>; }; appsById?: Map>; + masker?: SharePathMasker; } = {}, ): Promise> { - const dirname = pathPosix.dirname(entry.path); + const visiblePath = opts.masker + ? await opts.masker.mask(entry.path) + : entry.path; + const dirname = pathPosix.dirname(visiblePath); const mimeType = fsEntryMimeType(entry); const pathComponents = entry.path.split('/'); @@ -444,7 +511,7 @@ export async function toLegacyEntry( uuid: entry.uuid, parent_id: entry.parentUid, parent_uid: entry.parentUid, - path: entry.path, + path: visiblePath, dirname, dirpath: dirname, name: entry.name, @@ -512,7 +579,7 @@ export async function toLegacyEntry( return response; } -export { normalizeAbsolutePath }; +export { normalizeAbsolutePath, expandUserPath }; // -- Signing --------------------------------------------------------- diff --git a/src/backend/services/fs/resolveNode.ts b/src/backend/services/fs/resolveNode.ts index be16fa6ec..66ed755ef 100644 --- a/src/backend/services/fs/resolveNode.ts +++ b/src/backend/services/fs/resolveNode.ts @@ -21,6 +21,7 @@ import { posix as pathPosix } from 'node:path'; import { HttpError } from '../../core/http/HttpError.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js'; +import { parseSharePath } from './sharePaths.js'; /** * Resolve an entry by one of several reference shapes (path, uid, id) to a @@ -178,6 +179,29 @@ export function expandTildePath(path: string, username?: string): string { return `/${username}${trimmed.slice(1)}`; } +/** + * Expand a caller-supplied path to the real one it names: `~/share//…` + * addresses an entry through a share, `~/…` the caller's own home. Translation + * only — whether they may touch it is still ACL's answer, exactly as for a + * request passing the uid directly. + */ +export async function expandUserPath( + fsEntryStore: FSEntryStore, + path: string, + username?: string, +): Promise { + const parsed = parseSharePath(path); + if (!parsed) return expandTildePath(path, username); + + const root = await fsEntryStore.getEntryByUuid(parsed.rootUid); + if (!root) { + throw new HttpError(404, `Entry not found: ${path}`, { + legacyCode: 'subject_does_not_exist', + }); + } + return parsed.rest ? `${root.path}/${parsed.rest}` : root.path; +} + /** * Build an absolute child path from a parent path + child name. Rejects names * containing `/`. diff --git a/src/backend/services/fs/sharePaths.test.ts b/src/backend/services/fs/sharePaths.test.ts new file mode 100644 index 000000000..3c87b7d1a --- /dev/null +++ b/src/backend/services/fs/sharePaths.test.ts @@ -0,0 +1,132 @@ +/* + * 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 { describe, expect, it } from 'vitest'; +import { + isShareRoot, + maskUnder, + parseSharePath, + toSharePath, +} from './sharePaths.js'; + +const UID = 'a4332293-4dbe-4f50-a9bc-2835928ce076'; + +describe('parseSharePath', () => { + it('reads the root on its own', () => { + expect(parseSharePath(`~/share/${UID}`)).toEqual({ + rootUid: UID, + rest: '', + }); + }); + + it('reads a path below the root', () => { + expect(parseSharePath(`~/share/${UID}/a/b.txt`)).toEqual({ + rootUid: UID, + rest: 'a/b.txt', + }); + }); + + it('tolerates a trailing slash', () => { + expect(parseSharePath(`~/share/${UID}/`)).toEqual({ + rootUid: UID, + rest: '', + }); + }); + + it('leaves a real folder the user made at ~/share alone', () => { + expect(parseSharePath('~/share')).toBeNull(); + expect(parseSharePath('~/share/notes.txt')).toBeNull(); + expect(parseSharePath('~/share/2024/photo.png')).toBeNull(); + }); + + it('leaves ordinary paths alone', () => { + expect(parseSharePath('/jf/Documents/a.txt')).toBeNull(); + expect(parseSharePath('~/Documents/a.txt')).toBeNull(); + expect(parseSharePath('')).toBeNull(); + expect(parseSharePath(undefined as unknown as string)).toBeNull(); + }); + + it('rejects a segment that only looks uid-ish', () => { + expect(parseSharePath('~/share/not-a-uid/a.txt')).toBeNull(); + expect(parseSharePath(`~/share/${UID}xyz/a.txt`)).toBeNull(); + }); +}); + +describe('toSharePath', () => { + it('addresses the root', () => { + expect(toSharePath(UID)).toBe(`~/share/${UID}`); + }); + + it('addresses something below it', () => { + expect(toSharePath(UID, 'a/b.txt')).toBe(`~/share/${UID}/a/b.txt`); + }); + + it('does not double the separator', () => { + expect(toSharePath(UID, '/a.txt')).toBe(`~/share/${UID}/a.txt`); + }); + + it('round-trips through parseSharePath', () => { + expect(parseSharePath(toSharePath(UID, 'a/b.txt'))).toEqual({ + rootUid: UID, + rest: 'a/b.txt', + }); + }); +}); + +describe('isShareRoot', () => { + it('recognizes the virtual directory itself', () => { + expect(isShareRoot('~/share')).toBe(true); + expect(isShareRoot(`~/share/${UID}`)).toBe(false); + expect(isShareRoot('/jf/share')).toBe(false); + }); +}); + +describe('maskUnder', () => { + it('masks the shared root itself', () => { + expect(maskUnder('/jf/Documents/Contents', '/jf/Documents/Contents', UID)).toBe( + `~/share/${UID}`, + ); + }); + + it('masks something inside the shared root', () => { + expect( + maskUnder( + '/jf/Documents/Contents/a/b.txt', + '/jf/Documents/Contents', + UID, + ), + ).toBe(`~/share/${UID}/a/b.txt`); + }); + + it('refuses a path outside the root', () => { + expect( + maskUnder('/jf/Documents/Other', '/jf/Documents/Contents', UID), + ).toBeNull(); + }); + + it('refuses a sibling whose name merely starts the same', () => { + expect( + maskUnder( + '/jf/Documents/ContentsBackup/a.txt', + '/jf/Documents/Contents', + UID, + ), + ).toBeNull(); + }); +}); diff --git a/src/backend/services/fs/sharePaths.ts b/src/backend/services/fs/sharePaths.ts new file mode 100644 index 000000000..af200daa4 --- /dev/null +++ b/src/backend/services/fs/sharePaths.ts @@ -0,0 +1,86 @@ +/* + * 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 . + */ + +/** + * Addresses for entries reached through a share. A recipient sees + * `~/share//rel/path`, which tells them what was shared without + * telling them where the owner keeps it or what sits alongside it. + */ + +/** Virtual directory holding one entry per share the actor holds. */ +export const SHARE_ROOT = '~/share'; + +const SHARE_ROOT_PREFIX = `${SHARE_ROOT}/`; + +// Deliberately strict: a real folder the user made at `~/share` must keep +// working, and the only thing distinguishing the two is whether the segment +// after it reads as an entry uid. +const UID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +export interface SharePathParts { + /** Uid of the shared entry the path is rooted at. */ + rootUid: string; + /** Path below that entry, `''` for the root itself. No leading slash. */ + rest: string; +} + +/** + * Read `~/share//rel/path`. Returns null for anything else, including a + * real `~/share/...` directory whose next segment is not a uid. + */ +export function parseSharePath(path: string): SharePathParts | null { + if (typeof path !== 'string') return null; + const trimmed = path.trim(); + if (trimmed === SHARE_ROOT) return null; + if (!trimmed.startsWith(SHARE_ROOT_PREFIX)) return null; + + const [rootUid, ...restParts] = trimmed + .slice(SHARE_ROOT_PREFIX.length) + .split('/'); + if (!rootUid || !UID.test(rootUid)) return null; + + return { rootUid, rest: restParts.filter(Boolean).join('/') }; +} + +/** Whether `path` addresses the virtual share directory itself. */ +export function isShareRoot(path: string): boolean { + return typeof path === 'string' && path.trim() === SHARE_ROOT; +} + +/** Build `~/share//rel/path`. */ +export function toSharePath(rootUid: string, rest = ''): string { + const suffix = rest.replace(/^\/+/u, ''); + return suffix + ? `${SHARE_ROOT_PREFIX}${rootUid}/${suffix}` + : `${SHARE_ROOT_PREFIX}${rootUid}`; +} + +/** + * Re-root a real path onto the shared root that reaches it. Returns null when + * `realPath` is not `rootPath` or below it. + */ +export function maskUnder( + realPath: string, + rootPath: string, + rootUid: string, +): string | null { + if (realPath === rootPath) return toSharePath(rootUid); + if (!realPath.startsWith(`${rootPath}/`)) return null; + return toSharePath(rootUid, realPath.slice(rootPath.length + 1)); +} diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index ed7e15c39..d1cf9d206 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -22,6 +22,7 @@ import { HttpError } from '../../core/http/HttpError.js'; import type { FSEntry } from '../../stores/fs/FSEntry'; import type { LayerInstances } from '../../types'; import type { AclMode } from '../acl/ACLService'; +import { toSharePath } from '../fs/sharePaths.js'; import type { puterServices } from '../index'; import { PuterService } from '../types'; @@ -47,9 +48,14 @@ export interface ShareInput extends ShareTarget { export interface ResolvedShare { uid: string; mode: string; + /** `~/share/` in a holder's listing; the real path in an owner's. */ path: string; + /** The entry's own name, which a share path no longer carries. */ + name?: string; entryUid: string; isDir: boolean; + /** Whose entry it is — the masked path no longer says. */ + owner?: { username: string | null }; issuer: { username: string | null }; holder: { username: string | null }; createdAt: unknown; @@ -466,23 +472,29 @@ export class ShareService extends PuterService { const entries = await this.stores.fsEntry.getEntriesByIds( page.items.map((row: { fsentry_id: number }) => row.fsentry_id), ); - const issuers = await this.stores.user.getByIds( - page.items.map((row: { issuer_user_id: number }) => + const issuers = await this.stores.user.getByIds([ + ...page.items.map((row: { issuer_user_id: number }) => Number(row.issuer_user_id), ), - ); + ...[...entries.values()].map((entry) => entry.userId), + ]); const items: ResolvedShare[] = []; for (const row of page.items) { const entry = entries.get(Number(row.fsentry_id)); if (!entry || this.#isTrashed(entry)) continue; const issuer = issuers.get(Number(row.issuer_user_id)); + const owner = issuers.get(Number(entry.userId)); items.push({ uid: row.uid, mode: row.mode, - path: entry.path, + // The address the recipient can actually use — their own view + // of the entry, not where the owner keeps it. + path: toSharePath(entry.uuid), + name: entry.name, entryUid: entry.uuid, isDir: Boolean(entry.isDir), + owner: { username: owner?.username ?? null }, issuer: { username: issuer?.username ?? null }, holder: { username: actor.user.username ?? null }, createdAt: row.created_at, @@ -764,6 +776,46 @@ export class ShareService extends PuterService { return /^\/[^/]+\/Trash(\/|$)/u.test(entry.path); } + // -- Virtual share paths ------------------------------------------ + + /** + * The share root `path` hangs from, for `actor` — the deepest ancestor they + * hold a share on, so a file is addressed relative to its shared folder. + */ + async findShareRoot( + actor: Actor, + path: string, + ): Promise<{ uid: string; path: string } | null> { + const holderId = actor.user?.id; + if (typeof holderId !== 'number') return null; + + const ancestors = await this.services.fs.getAncestorChain(path); + if (ancestors.length === 0) return null; + + const entries = await this.stores.fsEntry.getEntriesByPaths( + ancestors.map((a) => a.path), + ); + const byId = new Map(); + for (const ancestor of ancestors) { + const entry = entries.get(ancestor.path); + if (entry) byId.set(entry.id, ancestor); + } + + const shares = await this.stores.share.listByHolderAmong(holderId, [ + ...byId.keys(), + ]); + if (shares.length === 0) return null; + + // `ancestors` runs deepest-first, so the first hit is the nearest root. + const shared = new Set( + shares.map((row: { fsentry_id: number }) => Number(row.fsentry_id)), + ); + for (const [id, ancestor] of byId) { + if (shared.has(id)) return ancestor; + } + return null; + } + /** * Clear whichever modes the recipient holds on this node. * diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 13f8e3e48..c330a92ef 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -100,6 +100,24 @@ export class ShareStore extends PuterStore { }; } + /** + * The holder's active shares among `fsentryIds`. Used to find which of an + * entry's ancestors the holder actually reached it through. + * + * @param {number} holderUserId + * @param {number[]} fsentryIds + */ + async listByHolderAmong(holderUserId, fsentryIds) { + const ids = [...new Set(fsentryIds.map(Number).filter(Boolean))]; + if (ids.length === 0) return []; + const rows = await this.clients.db.read( + 'SELECT * FROM `share` WHERE `holder_user_id` = ? AND ' + + `\`fsentry_id\` IN (${ids.map(() => '?').join(', ')})`, + [holderUserId, ...ids], + ); + return rows.map((r) => this.#normalizeRow(r)); + } + /** Everyone with an active share on one node, whoever issued it. */ async listByFsentry(fsentryId) { const rows = await this.clients.db.read( diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index 6dde6fd65..144826e7a 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -135,6 +135,7 @@ async function UIItem (options) { options.shared_with_me = options.shared_with_me ?? false; options.share_mode = options.share_mode ?? ''; options.shared_by = options.shared_by ?? ''; + options.owner = options.owner ?? ''; options.metadata = options.metadata ?? ''; options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false; options.shortcut_to = options.shortcut_to ?? ''; @@ -170,6 +171,7 @@ async function UIItem (options) { data-shared_with_me="${options.shared_with_me ? 1 : 0}" data-share_mode="${html_encode(options.share_mode)}" data-shared_by="${html_encode(options.shared_by)}" + data-owner="${html_encode(options.owner)}" data-has_website="${show_website_badge ? 1 : 0 }" data-website_url = "${website_url ? html_encode(website_url) : ''}" data-immutable="${options.immutable}" diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 09be089df..10b47037f 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -30,6 +30,7 @@ import launch_app from '../helpers/launch_app.js'; import publish_as_website from '../helpers/publish_as_website.js'; import item_icon from '../helpers/item_icon.js'; +import { parent_path_for } from '../helpers/share_paths.js'; const el_body = document.getElementsByTagName('body')[0]; const SNAP_PLACEHOLDER_DELAY_MS = 600; // delay before showing placeholder in any snap zone @@ -1210,7 +1211,10 @@ async function UIWindow (options) { // Up button // -------------------------------------------------------- $(el_window_navbar_up_btn).on('click', function (e) { - const target_path = path.resolve(path.join($(el_window).attr('data-path'), '..')); + const target_path = parent_path_for( + $(el_window).attr('data-path'), + (p) => path.resolve(path.join(p, '..')), + ); // if ctrl/cmd are pressed, open in new window if ( e.ctrlKey || e.metaKey && (target_path !== undefined && target_path !== null) ) { UIWindow({ diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 9c4de4a28..dfcddf487 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -1782,7 +1782,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { // Deleting sends an item to its owner's trash, not yours. const is_trashing = dest_path === window.trash_path; const item_dest_path = is_trashing - ? trash_path_for($(el_item).attr('data-path')) + ? trash_path_for( + $(el_item).attr('data-path'), + $(el_item).attr('data-owner'), + ) : dest_path; // cannot move item to its own path, skip it diff --git a/src/gui/src/helpers/path_owner.js b/src/gui/src/helpers/path_owner.js index 953f43b1d..ff32e9baf 100644 --- a/src/gui/src/helpers/path_owner.js +++ b/src/gui/src/helpers/path_owner.js @@ -43,10 +43,15 @@ export const is_owned_by_me = (path) => { }; /** - * Trash an item at `path` belongs in — its owner's, not yours. + * Trash an item belongs in — its owner's, not yours. A shared item's path is + * masked (`~/share//…`) and names nobody, so the owner is read off the + * entry when the path cannot supply it. * * @param {string} path + * @param {string} [owner] username from the entry, when known * @returns {string} */ -export const trash_path_for = (path) => - `/${owner_of_path(path) ?? window.user?.username}/Trash`; +export const trash_path_for = (path, owner) => { + const from_path = path?.startsWith('~') ? null : owner_of_path(path); + return `/${owner || from_path || window.user?.username}/Trash`; +}; diff --git a/src/gui/src/helpers/refresh_item_container.js b/src/gui/src/helpers/refresh_item_container.js index 69b5bf85a..844f9f818 100644 --- a/src/gui/src/helpers/refresh_item_container.js +++ b/src/gui/src/helpers/refresh_item_container.js @@ -134,7 +134,9 @@ const refresh_item_container = function (el_item_container, options) { return shares; }).then((shares) => shares.map((share) => ({ uid: share.entryUid, - name: path.basename(share.path), + // A share path is `~/share/`, so the name comes off the + // share rather than off the path. + name: share.name ?? path.basename(share.path), path: share.path, is_dir: share.isDir, modified: share.modified, @@ -144,6 +146,7 @@ const refresh_item_container = function (el_item_container, options) { shared_with_me: true, share_mode: share.mode, shared_by: share.issuer, + owner: share.owner?.username, metadata: '', }))) : puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' }); @@ -249,6 +252,7 @@ const refresh_item_container = function (el_item_container, options) { shared_with_me: fsentry.shared_with_me, share_mode: fsentry.share_mode, shared_by: fsentry.shared_by, + owner: fsentry.owner?.username ?? fsentry.owner, }); } } diff --git a/src/gui/src/helpers/share_paths.js b/src/gui/src/helpers/share_paths.js new file mode 100644 index 000000000..afdf857df --- /dev/null +++ b/src/gui/src/helpers/share_paths.js @@ -0,0 +1,49 @@ +/* + * 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 . + */ + +/** + * The client half of the `~/share//…` addresses the backend hands out for + * anything reached through a share. Mirrors `services/fs/sharePaths.ts`. + */ + +const SHARE_ROOT = '~/share'; +const UID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * @param {string} p + * @returns {boolean} + */ +export const is_share_path = (p) => { + if ( typeof p !== 'string' || ! p.startsWith(`${SHARE_ROOT}/`) ) return false; + return UID.test(p.slice(SHARE_ROOT.length + 1).split('/')[0] ?? ''); +}; + +/** + * Where "up" leads from `p`. A shared root's parent is the Shared view rather + * than `~/share`, which is an address the backend does not serve. + * + * @param {string} p + * @param {(p: string) => string} resolve fallback for ordinary paths + * @returns {string} + */ +export const parent_path_for = (p, resolve) => { + if ( ! is_share_path(p) ) return resolve(p); + const rest = p.slice(SHARE_ROOT.length + 1); + return rest.includes('/') ? resolve(p) : window.shared_path; +}; diff --git a/src/gui/src/helpers/share_paths.test.js b/src/gui/src/helpers/share_paths.test.js new file mode 100644 index 000000000..a6624df00 --- /dev/null +++ b/src/gui/src/helpers/share_paths.test.js @@ -0,0 +1,90 @@ +/* + * 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 { beforeEach, describe, expect, it, vi } from 'vitest'; +import { is_share_path, parent_path_for } from './share_paths.js'; +import { trash_path_for } from './path_owner.js'; + +const UID = 'a4332293-4dbe-4f50-a9bc-2835928ce076'; + +beforeEach(() => { + globalThis.window = { shared_path: 'puter://shared', user: { username: 'me' } }; +}); + +describe('is_share_path', () => { + it('recognizes a shared root and what is under it', () => { + expect(is_share_path(`~/share/${UID}`)).toBe(true); + expect(is_share_path(`~/share/${UID}/a/b.txt`)).toBe(true); + }); + + it('leaves a real folder at ~/share alone', () => { + expect(is_share_path('~/share')).toBe(false); + expect(is_share_path('~/share/notes.txt')).toBe(false); + }); + + it('leaves ordinary paths alone', () => { + expect(is_share_path('/me/Documents/a.txt')).toBe(false); + expect(is_share_path(undefined)).toBe(false); + }); +}); + +describe('parent_path_for', () => { + const resolve = vi.fn((p) => `resolved(${p})`); + + beforeEach(() => resolve.mockClear()); + + it('sends a shared root back to the Shared view', () => { + expect(parent_path_for(`~/share/${UID}`, resolve)).toBe( + 'puter://shared', + ); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('resolves normally inside a shared root', () => { + expect(parent_path_for(`~/share/${UID}/a/b.txt`, resolve)).toBe( + `resolved(~/share/${UID}/a/b.txt)`, + ); + }); + + it('resolves ordinary paths normally', () => { + expect(parent_path_for('/me/Documents/a.txt', resolve)).toBe( + 'resolved(/me/Documents/a.txt)', + ); + }); +}); + +describe('trash_path_for', () => { + it('uses the owner carried on the entry for a masked path', () => { + expect(trash_path_for(`~/share/${UID}/a.txt`, 'jf')).toBe('/jf/Trash'); + }); + + it('reads the owner off an ordinary path', () => { + expect(trash_path_for('/jf/Documents/a.txt')).toBe('/jf/Trash'); + }); + + it('prefers an explicit owner over the path', () => { + expect(trash_path_for('/jf/Documents/a.txt', 'other')).toBe( + '/other/Trash', + ); + }); + + it('falls back to your own trash when nothing names an owner', () => { + expect(trash_path_for(`~/share/${UID}/a.txt`)).toBe('/me/Trash'); + }); +});