feat(fs): address shared entries as ~/share/<uid>

A recipient could read the owner's whole path off any shared entry —
where they keep the file and what sits beside it, neither of which the
share is about.

Give shares their own namespace. `~/share/<entry-uid>/rel/path` resolves
to the real path on the way in, and outgoing paths are rewritten to it
on the way out. Entries the actor owns pass through untouched, so no
existing client contract moves.
This commit is contained in:
Juan Castro
2026-08-14 18:42:33 -04:00
parent 2371b51b25
commit fe535d5984
16 changed files with 828 additions and 66 deletions
+46 -25
View File
@@ -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<ClientFSEntry> {
// 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<T extends { fsEntry?: FSEntry }>(
async #withClientFsEntry<T extends { fsEntry?: FSEntry }>(
response: T,
): Omit<T, 'fsEntry'> & { fsEntry?: ClientFSEntry } {
): Promise<Omit<T, 'fsEntry'> & { 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<T extends { fsEntry: FSEntry }>(
async #withRequiredClientFsEntry<T extends { fsEntry: FSEntry }>(
response: T,
): Omit<T, 'fsEntry'> & { fsEntry: ClientFSEntry } {
): Promise<Omit<T, 'fsEntry'> & { 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'
@@ -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 owners 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 actors 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', () => {
@@ -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<void> => {
@@ -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<void> => {
@@ -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<Record<string, unknown> | null>;
};
}
#masker(): SharePathMasker {
return SharePathMasker.forRequest(this.services.share);
}
#serializeBatchError(err: unknown): Record<string, unknown> {
+73 -6
View File
@@ -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/<root-uid>/…` 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<string, { uid: string; path: string } | null>();
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<string> {
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<Record<string, unknown> | null>;
};
appsById?: Map<number, Record<string, unknown>>;
masker?: SharePathMasker;
} = {},
): Promise<Record<string, unknown>> {
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 ---------------------------------------------------------
+24
View File
@@ -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/<uid>/…`
* 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<string> {
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 `/`.
+132
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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();
});
});
+86
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* Addresses for entries reached through a share. A recipient sees
* `~/share/<root-uid>/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/<uid>/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/<uid>/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));
}
+56 -4
View File
@@ -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/<entryUid>` 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<number, { uid: string; path: string }>();
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.
*
+18
View File
@@ -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(
+2
View File
@@ -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}"
+5 -1
View File
@@ -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({
+4 -1
View File
@@ -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
+8 -3
View File
@@ -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/<uid>/…`) 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`;
};
@@ -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/<uid>`, 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,
});
}
}
+49
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* The client half of the `~/share/<uid>/…` 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;
};
+90
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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');
});
});