fix: misc hardening + other fixes (#3906)

This commit is contained in:
Daniel Salazar
2026-09-19 12:57:57 -07:00
committed by GitHub
parent 9292771554
commit eb9f03e984
37 changed files with 2016 additions and 144 deletions
@@ -328,6 +328,9 @@ export class FSController extends PuterController {
req.body,
);
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
await this.#assertUploadSessionWriteAccess(req, userId, [
requestBody.uploadId,
]);
const response = await this.services.fs.completeUrlWrite(
userId,
@@ -373,6 +376,11 @@ export class FSController extends PuterController {
for (const requestBody of requests) {
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
}
await this.#assertUploadSessionWriteAccess(
req,
userId,
requests.map((requestBody) => requestBody.uploadId),
);
const response = await this.services.fs.batchCompleteUrlWrite(
userId,
requests,
@@ -433,6 +441,9 @@ export class FSController extends PuterController {
res: Response<ClientSignMultipartPartsResponse>,
) {
const userId = this.#getActorUserId(req);
await this.#assertUploadSessionWriteAccess(req, userId, [
req.body?.uploadId,
]);
const response = await this.services.fs.signMultipartParts(
userId,
req.body,
@@ -2511,6 +2522,40 @@ export class FSController extends PuterController {
);
}
/**
* An upload session stays usable for as long as it lives, so the access
* `startWrite` checked has to be re-checked against the session's recorded
* target every time the caller signs more parts or completes the upload —
* otherwise a revoked sharee still lands bytes in the owner's tree.
*/
async #assertUploadSessionWriteAccess(
req: Request,
userId: number,
uploadIds: Array<string | undefined>,
): Promise<void> {
// A malformed id is left to the service, which owns that error shape.
const knownUploadIds = uploadIds.filter(
(uploadId): uploadId is string =>
typeof uploadId === 'string' && uploadId.length > 0,
);
if (knownUploadIds.length === 0) {
return;
}
const sessions = await this.services.fs.getUploadSessions(
userId,
knownUploadIds,
);
await this.#assertBatchWriteAccess(
req,
sessions.map((session) => ({
path: session.targetPath,
size: session.size,
overwrite: Boolean(session.overwriteTargetUid),
})),
{ pathAlreadyNormalized: true },
);
}
#toEventGuiMetadata(
guiMetadata: WriteGuiMetadata | undefined,
includeOriginalClientSocketId = true,
@@ -1921,3 +1921,211 @@ describe('FSController.batchWrites (multipart)', () => {
).toBeNull();
});
});
// -- upload sessions outliving their share ----------------------------
//
// An upload session is authorized once, at `/fs/startWrite`. Resuming or
// completing one has to re-check that the caller still has write access to the
// session's target, or a revoked sharee lands bytes in the owner's tree.
describe('FSController upload session access re-checks', () => {
const shareWritableFolder = async (folderName: string) => {
const owner = await makeUser();
const recipient = await makeUser();
const folder = `/${owner.username}/Documents/${folderName}`;
await withActor(owner.actor, () =>
controller.startWrite(
makeReq<SignedWriteRequest>({
body: {
fileMetadata: {
path: folder,
size: 0,
createMissingParents: true,
},
directory: true,
},
actor: owner.actor,
}),
makeRes().res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(folder);
// Read stays granted so the denial is a plain 403 rather than the
// existence-masking 404 an invisible path gets.
for (const mode of ['read', 'write']) {
await server.services.permission.grantUserUserPermission(
owner.actor,
recipient.username,
`fs:${entry!.uuid}:${mode}`,
{},
);
}
const revokeWrite = () =>
server.services.permission.revokeUserUserPermission(
owner.actor,
recipient.username,
`fs:${entry!.uuid}:write`,
);
return { owner, recipient, folder, revokeWrite };
};
const startUpload = async (
actor: Actor,
path: string,
extra: Partial<SignedWriteRequest> = {},
) => {
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.startWrite(
makeReq<SignedWriteRequest>({
body: { fileMetadata: { path, size: 8 }, ...extra },
actor,
}),
res,
),
);
return captured.body as ClientSignedWriteResponse;
};
it('refuses to sign more parts once the share is revoked', async () => {
const { recipient, folder, revokeWrite } =
await shareWritableFolder('revoked-parts');
const started = await startUpload(
recipient.actor,
`${folder}/upload.bin`,
{ uploadMode: 'multipart' },
);
await revokeWrite();
const { res } = makeRes();
await expect(
withActor(recipient.actor, () =>
controller.signMultipartParts(
makeReq<SignMultipartPartsRequest>({
body: {
uploadId: started.sessionId,
partNumbers: [1],
},
actor: recipient.actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('refuses to complete a session once the share is revoked', async () => {
const { recipient, folder, revokeWrite } =
await shareWritableFolder('revoked-complete');
const target = `${folder}/upload.bin`;
const started = await startUpload(recipient.actor, target);
await revokeWrite();
const { res } = makeRes();
await expect(
withActor(recipient.actor, () =>
controller.completeWrite(
makeReq<CompleteWriteRequest>({
body: { uploadId: started.sessionId },
actor: recipient.actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
});
it('refuses the batch completion once the share is revoked', async () => {
const { recipient, folder, revokeWrite } =
await shareWritableFolder('revoked-batch');
const targets = [`${folder}/batch-a.bin`, `${folder}/batch-b.bin`];
const started = [
await startUpload(recipient.actor, targets[0]!),
await startUpload(recipient.actor, targets[1]!),
];
await revokeWrite();
const { res } = makeRes();
await expect(
withActor(recipient.actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: started.map((s) => ({ uploadId: s.sessionId })),
actor: recipient.actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
for (const target of targets) {
expect(
await server.stores.fsEntry.getEntryByPath(target),
).toBeNull();
}
});
it('still signs parts and completes while the share stands', async () => {
const { recipient, folder } = await shareWritableFolder('kept-share');
const multipart = await startUpload(
recipient.actor,
`${folder}/kept-parts.bin`,
{ uploadMode: 'multipart' },
);
const signed = makeRes();
await withActor(recipient.actor, () =>
controller.signMultipartParts(
makeReq<SignMultipartPartsRequest>({
body: { uploadId: multipart.sessionId, partNumbers: [1] },
actor: recipient.actor,
}),
signed.res,
),
);
expect(
(signed.captured.body as { multipartPartUrls: unknown[] })
.multipartPartUrls,
).toHaveLength(1);
const target = `${folder}/kept-complete.bin`;
const single = await startUpload(recipient.actor, target);
await withActor(recipient.actor, () =>
controller.completeWrite(
makeReq<CompleteWriteRequest>({
body: { uploadId: single.sessionId },
actor: recipient.actor,
}),
makeRes().res,
),
);
expect(
await server.stores.fsEntry.getEntryByPath(target),
).not.toBeNull();
});
it('completes a batch while the share stands', async () => {
const { recipient, folder } = await shareWritableFolder('kept-batch');
const targets = [
`${folder}/kept-batch-a.bin`,
`${folder}/kept-batch-b.bin`,
];
const started = [
await startUpload(recipient.actor, targets[0]!),
await startUpload(recipient.actor, targets[1]!),
];
await withActor(recipient.actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: started.map((s) => ({ uploadId: s.sessionId })),
actor: recipient.actor,
}),
makeRes().res,
),
);
for (const target of targets) {
expect(
await server.stores.fsEntry.getEntryByPath(target),
).not.toBeNull();
}
});
});
@@ -955,6 +955,17 @@ describe('OIDCController login callback', () => {
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
expect(captured.redirectUrl).toContain('oidc_login=true');
// The proof must bind to the account that just completed OIDC so the
// popup can refuse a proof replayed in another signed-in browser.
const openerState = new URL(captured.redirectUrl!).searchParams.get(
'opener_state',
);
const proof = oidc().verifyPopupReturn(openerState!);
expect(proof?.oidc_login).toBe(true);
const user = await server.stores.user.getByEmail(email);
expect(user?.uuid).toBeTruthy();
expect(proof?.user_uuid).toBe(user!.uuid);
});
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
@@ -1846,9 +1857,23 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
user_uuid: null,
});
});
it('hands back the account a proof is bound to', async () => {
// The popup compares this against its current user to reject a proof
// replayed from another account's login.
const proof = server.services.oidc.signPopupReturn({
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
user_uuid: 'user-A',
});
const captured = await redeem(proof);
expect(captured.body).toMatchObject({ user_uuid: 'user-A' });
});
it('rejects a proof signed with someone else’s key', async () => {
// The whole point: only the server can mint one of these.
const forged = jwt.sign(
@@ -302,6 +302,7 @@ export class OIDCController extends PuterController {
opener_origin: decoded.opener_origin ?? null,
msg_id: decoded.msg_id ?? null,
oidc_login: decoded.oidc_login === true,
user_uuid: decoded.user_uuid ?? null,
});
},
);
@@ -924,6 +925,11 @@ if (window.opener) {
// identity to mint a token for, so it needs the integrity this
// state already carries — re-signed here, at the one point where
// the round trip is known to have actually happened.
//
// `user_uuid` binds the proof to the account that just completed
// OIDC. Without it a proof from one login could be replayed in
// another signed-in browser to skip its account picker; the popup
// only honors `oidc_login` when this matches its current user.
target = appendQueryParam(
target,
'opener_state',
@@ -931,6 +937,7 @@ if (window.opener) {
opener_origin: stateDecoded.opener_origin ?? null,
msg_id: stateDecoded.msg_id ?? null,
oidc_login: true,
user_uuid: user.uuid,
}),
);
}
@@ -24,6 +24,7 @@ import { v4 as uuidv4 } from 'uuid';
import { PuterServer } from '../../../server';
import { setupTestServer } from '../../../testUtil';
import type { IConfig } from '../../../types';
import type { Actor } from '../../actor';
import { generateDefaultFsentries } from '../../../util/userProvisioning';
import { createPuterSiteMiddleware } from './puterSite';
@@ -1451,3 +1452,151 @@ describe('createPuterSiteMiddleware — hosting CSP', () => {
expect(out.headers['Content-Security-Policy']).toBeUndefined();
});
});
// -- Sites rooted in someone else's directory ------------------------
//
// Hosting serves everything under the site root with the ACL bypassed, so a
// site published from a shared folder rides on the `manage` grant that
// authorized it. These pin that the grant is re-read on every request, and
// that the ordinary owner-rooted site pays nothing for it.
const actorFor = (user: {
id: number;
uuid: string;
username: string;
}): Actor =>
({
user: { id: user.id, uuid: user.uuid, username: user.username },
effectiveApp: null,
}) as Actor;
const descriptorFor = (path: string) => ({
path,
resolveAncestors: () => server.services.fs.getAncestorChain(path),
});
const serveSite = async (sub: string) => {
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/index.html',
}),
res,
vi.fn(),
);
// Allow the piped stream to flush.
await new Promise<void>((resolve) => setImmediate(resolve));
return out;
};
describe('createPuterSiteMiddleware — delegated site roots', () => {
// Owner shares a directory at `manage`; the delegate points a subdomain
// at it, which is exactly what `SubdomainDriver` permits.
const publishSharedDir = async () => {
const owner = await makeUserWithHome();
const delegate = await makeUserWithHome();
const dirPath = `/${owner.username}/Documents`;
const dirEntry = (await server.stores.fsEntry.getEntryByPath(dirPath))!;
await writeFile(
owner.id,
`${dirPath}/index.html`,
Buffer.from('<html>shared</html>'),
'text/html',
);
await server.services.acl.setUserUser(
actorFor(owner),
actorFor(delegate),
descriptorFor(dirPath),
'manage',
);
const sub = `shared-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: delegate.id,
subdomain: sub,
rootDirId: dirEntry.id,
});
return { owner, delegate, dirEntry, sub };
};
it('serves a site whose publisher still holds `manage` on the root', async () => {
const { sub } = await publishSharedDir();
const out = await serveSite(sub);
expect(out.statusCode).toBe(200);
expect((out.body as Buffer).toString()).toBe('<html>shared</html>');
});
it("stops serving once the owner withdraws the publisher's `manage` grant", async () => {
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
expect((await serveSite(sub)).statusCode).toBe(200);
await server.services.permission.revokeUserUserPermission(
actorFor(owner),
delegate.username,
`manage:fs:${dirEntry.uuid}`,
);
const out = await serveSite(sub);
expect(out.statusCode).toBe(404);
expect(out.contentType).toBe('text/html; charset=UTF-8');
expect(String(out.body)).toContain('404');
});
it('stops serving once the owner unshares the directory', async () => {
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
expect((await serveSite(sub)).statusCode).toBe(200);
await server.services.share.unshare(actorFor(owner), {
uid: dirEntry.uuid,
recipient: { username: delegate.username },
});
expect((await serveSite(sub)).statusCode).toBe(404);
});
it('stops serving once the owner moves the root into their Trash', async () => {
// The grant is keyed on the node, so it survives the move — the
// trashed location is what makes the site unservable.
const { owner, dirEntry, sub } = await publishSharedDir();
expect((await serveSite(sub)).statusCode).toBe(200);
const trash = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}/Trash`,
))!;
await server.services.fs.move(owner.id, {
source: dirEntry,
destinationParent: trash,
});
expect((await serveSite(sub)).statusCode).toBe(404);
});
it("never consults the ACL for a site rooted in the publisher's own tree", async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry =
(await server.stores.fsEntry.getEntryByPath(homePath))!;
await writeFile(
owner.id,
`${homePath}/index.html`,
Buffer.from('<html>mine</html>'),
'text/html',
);
const sub = `own-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry.id,
});
const aclCheck = vi.spyOn(server.services.acl, 'check');
try {
const out = await serveSite(sub);
expect(out.statusCode).toBe(200);
expect(aclCheck).not.toHaveBeenCalled();
} finally {
aclCheck.mockRestore();
}
});
});
@@ -22,8 +22,10 @@ import { contentType as contentTypeFromMime } from 'mime-types';
import { posix as pathPosix } from 'node:path';
import type { puterClients } from '../../../clients';
import type { puterServices } from '../../../services';
import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
import type { puterStores } from '../../../stores';
import type { IConfig, LayerInstances } from '../../../types';
import { makeActor } from '../../actor';
import {
buildAppCenterFallback,
buildHostingConfig,
@@ -89,6 +91,10 @@ const isWorkersSourcePath = (urlPath: string): boolean =>
.split('/')
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
/** Inside some owner's top-level Trash, which is where Delete puts things. */
const isTrashedPath = (path: string): boolean =>
/^\/[^/]+\/Trash(\/|$)/u.test(path);
/**
* Suggested value for `config.hosting_csp`. Not applied unless configured.
*
@@ -482,6 +488,49 @@ export const createPuterSiteMiddleware = (
return;
}
// A site rooted in someone else's directory stands on the `manage`
// grant that authorized publishing it, and serves that whole subtree
// with the ACL bypassed — so the grant is re-read on every request.
// Trash is its own check: a grant is keyed on the node, not its
// location, so it survives the owner deleting the directory. Sites
// rooted in their publisher's own tree skip all of this.
if (rootEntry.userId !== site.user_id) {
let stillPublishable = false;
if (!isTrashedPath(rootEntry.path)) {
try {
stillPublishable = await layers.services.acl.check(
makeActor({
user: {
id: owner.id,
uuid: owner.uuid,
username: owner.username,
},
}),
{
path: rootEntry.path,
resolveAncestors: () =>
layers.services.fs.getAncestorChain(
rootEntry.path,
),
},
MANAGE_PERM_PREFIX,
);
} catch (e) {
// Fail closed — an unreadable grant is not a held one.
console.warn(
'[puter-site] publish grant recheck failed',
e,
);
}
}
if (!stillPublishable) {
res.status(404)
.type('text/html; charset=UTF-8')
.send(SUBDOMAIN_404);
return;
}
}
// Resolve URL path → absolute FS path under the site root.
let urlPath = req.path || '/';
if (urlPath.endsWith('/')) urlPath += 'index.html';
+42 -1
View File
@@ -64,7 +64,11 @@ import { MANAGE_PERM_PREFIX } from '../permission/consts.js';
import { PermissionUtil } from '../permission/permissionUtil.js';
import { PuterService } from '../types.js';
import { FSEntryCacheInvalidationEventHandler } from './cacheInvalidation.js';
import { isTildePath, normalizeAbsolutePath } from './resolveNode.js';
import {
isOwnersTrash,
isTildePath,
normalizeAbsolutePath,
} from './resolveNode.js';
import type {
BatchWritePrepareRequest,
NormalizedWriteInput,
@@ -2268,6 +2272,36 @@ export class FSService extends PuterService {
};
}
/**
* Read-only lookup of pending upload sessions, one per id in request order.
* Callers use it to re-authorize a session's target path before resuming or
* completing an upload, since the session outlives the access check
* `startWrite` made.
*/
async getUploadSessions(
userId: number,
uploadIds: string[],
): Promise<PendingUploadSession[]> {
if (uploadIds.length === 0) {
return [];
}
const sessions =
await this.stores.fsEntry.getPendingEntriesBySessionIds(uploadIds);
return sessions.map((session) => {
if (!session) {
throw new HttpError(404, 'Upload session was not found', {
legacyCode: 'not_found',
});
}
if (session.userId !== userId) {
throw new HttpError(403, 'Upload session access denied', {
legacyCode: 'forbidden',
});
}
return session;
});
}
async signMultipartParts(
userId: number,
request: SignMultipartPartsRequest,
@@ -4079,6 +4113,13 @@ export class FSService extends PuterService {
this.#dispatchEvents('fs.move.node', updated, {
movedFrom: { path: source.path },
});
// Deleting is a move into Trash, and a grant follows its entry there,
// so recipients keep their access unless it is withdrawn here. Awaited,
// and after the events, so the holders still hear the item go.
if (isOwnersTrash(source, destinationParent)) {
await this.services.share.onEntryTrashed(updated);
}
return updated;
}
+451 -7
View File
@@ -64,8 +64,15 @@ describe('ShareService', () => {
const name = `f-${uuid.slice(0, 8)}.txt`;
const path = `/${owner.username}/${name}`;
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)',
[uuid, name, path, owner.id, Math.floor(Date.now() / 1000)],
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
[
uuid,
name,
path,
owner.id,
server.clients.db.booleanValue(false),
Math.floor(Date.now() / 1000),
],
);
const entry = await server.stores.fsEntry.getEntryByPath(path);
if (!entry) throw new Error('fsentry not created');
@@ -85,17 +92,25 @@ describe('ShareService', () => {
const now = Math.floor(Date.now() / 1000);
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 1, ?)',
[dirUuid, dirName, dirPath, owner.id, now],
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
[
dirUuid,
dirName,
dirPath,
owner.id,
server.clients.db.booleanValue(true),
now,
],
);
const dirRow = await server.stores.fsEntry.getEntryByPath(dirPath);
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)',
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
fileUuid,
fileName,
`${dirPath}/${fileName}`,
owner.id,
server.clients.db.booleanValue(false),
now,
dirRow!.id,
dirUuid,
@@ -808,6 +823,130 @@ describe('ShareService', () => {
await server.stores.share.listPendingOnFsentry(file.id),
).toEqual([]);
});
it("is not an app's to open, at any reach it was given", async () => {
const owner = await makeUser();
paid.add(owner.user.uuid);
const file = await makeFile(owner.user);
for (const mode of ['read', 'write'] as const) {
const app = await makeApp(owner.user.id);
await grantAppReach(owner, app, file, mode);
await expect(
share(asApp(owner, app), {
uid: file.uuid,
recipient: anyone,
mode,
}),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
}
expect(await linkRows(file.id)).toEqual([]);
// The owner in person still can.
await share(owner.actor, {
uid: file.uuid,
recipient: anyone,
mode: 'read',
});
expect(await linkRows(file.id)).toHaveLength(1);
});
it("is not an app's to close either", async () => {
const owner = await makeUser();
paid.add(owner.user.uuid);
const stranger = await makeUser();
const file = await makeFile(owner.user);
const app = await makeApp(owner.user.id);
await grantAppReach(owner, app, file);
const created = await share(owner.actor, {
uid: file.uuid,
recipient: anyone,
mode: 'read',
});
await expect(
unshare(asApp(owner, app), {
uid: file.uuid,
recipient: anyone,
}),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'forbidden',
});
await expect(
runWithContext({ actor: asApp(owner, app) }, () =>
server.services.share.revokeSharedByMe(
asApp(owner, app),
created.uid,
),
),
).rejects.toMatchObject({ statusCode: 404 });
expect(await canRead(stranger.actor, file.path)).toBe(true);
// The owner in person still can.
expect(
await unshare(owner.actor, {
uid: file.uuid,
recipient: anyone,
}),
).toEqual({ revoked: 1 });
});
it('goes with a folder above it that changes owner', async () => {
const attacker = await makeUser();
paid.add(attacker.user.uuid);
const victim = await makeUser();
// On a plan too, so a row that survived would still answer.
paid.add(victim.user.uuid);
const stranger = await makeUser();
const { dir, file } = await makeDirWithFile(attacker.user);
// Theirs to open while they still own it.
await share(attacker.actor, {
uid: file.uuid,
recipient: anyone,
mode: 'write',
});
expect(await canWrite(stranger.actor, file.path)).toBe(true);
// What a move into someone else's tree does: the whole subtree
// changes hands, and only its root reaches the cleanup.
for (const entry of [dir, file]) {
await server.stores.fsEntry.updateEntry(entry.uuid, {
userId: victim.user.id,
});
}
const moved = await server.stores.fsEntry.getEntryByUuid(dir.uuid);
await server.services.share.onEntryOwnerChanged(moved!);
expect(await linkRows(file.id)).toEqual([]);
expect(await canWrite(stranger.actor, file.path)).toBe(false);
});
it('shows the owner a link row another issuer left behind', async () => {
const owner = await makeUser();
paid.add(owner.user.uuid);
const other = await makeUser();
const file = await makeFile(owner.user);
// Only a missed cleanup writes one of these, and hiding it is how
// the owner ends up unable to find the thing granting access.
await server.stores.share.upsertAnyone({
issuerUserId: other.user.id,
fsentryId: file.id,
mode: 'write',
});
expect(
(await server.services.share.listSharedByMe(owner.actor)).items,
).toContainEqual(
expect.objectContaining({ anyone: true, entryUid: file.uuid }),
);
});
});
describe('the listing flag', () => {
@@ -2314,6 +2453,149 @@ describe('ShareService', () => {
).toEqual([]);
});
it('leaves a holder’s re-shares alone when the revoke reaches none of their authority', async () => {
const owner = await makeUser();
const delegate = await makeUser();
const holder = await makeUser();
const third = await makeUser();
const file = await makeFile(owner.user);
// Both delegates manage by the owner's grant; `delegate` gave the
// holder nothing at all.
for (const recipient of [delegate, holder]) {
await share(owner.actor, {
uid: file.uuid,
recipient: { username: recipient.user.username },
mode: 'manage',
});
}
await share(holder.actor, {
uid: file.uuid,
recipient: { username: third.user.username },
mode: 'read',
});
const result = await unshare(delegate.actor, {
uid: file.uuid,
recipient: { username: holder.user.username },
});
expect(result.revoked).toBe(0);
expect(await canRead(holder.actor, file.path)).toBe(true);
expect(await canRead(third.actor, file.path)).toBe(true);
});
it('leaves a re-share standing when manage survives on the folder above', async () => {
const owner = await makeUser();
const holder = await makeUser();
const third = await makeUser();
const { dir, file } = await makeDirWithFile(owner.user);
// Two grants of manage: one on the folder, one on the file itself.
for (const uid of [dir.uuid, file.uuid]) {
await share(owner.actor, {
uid,
recipient: { username: holder.user.username },
mode: 'manage',
});
}
await share(holder.actor, {
uid: file.uuid,
recipient: { username: third.user.username },
mode: 'read',
});
// Only the file's grant goes; the folder still carries manage.
await unshare(owner.actor, {
uid: file.uuid,
recipient: { username: holder.user.username },
});
expect(await canRead(holder.actor, file.path)).toBe(true);
expect(await canRead(third.actor, file.path)).toBe(true);
});
it('still takes the re-shares when the revoke is what held them up', async () => {
const owner = await makeUser();
const delegate = await makeUser();
const holder = await makeUser();
const third = await makeUser();
const file = await makeFile(owner.user);
// `delegate` is a bystander here: the holder's manage is the owner's
// grant, and that is what the owner withdraws.
await share(owner.actor, {
uid: file.uuid,
recipient: { username: delegate.user.username },
mode: 'manage',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { username: holder.user.username },
mode: 'manage',
});
await share(holder.actor, {
uid: file.uuid,
recipient: { username: third.user.username },
mode: 'read',
});
await unshare(owner.actor, {
uid: file.uuid,
recipient: { username: holder.user.username },
});
expect(await canRead(holder.actor, file.path)).toBe(false);
expect(await canRead(third.actor, file.path)).toBe(false);
expect(await canRead(delegate.actor, file.path)).toBe(true);
});
it('leaves a member’s re-shares alone when their manage is not the team’s', async () => {
const owner = await makeUser();
const member = await makeUser();
const bystander = await makeUser();
const third = await makeUser();
const file = await makeFile(owner.user);
const team = await server.stores.team.create({
ownerUserId: owner.user.id,
name: `t-${uuidv4().slice(0, 8)}`,
});
for (const each of [owner, member, bystander]) {
await server.stores.team.addMember(team.uid, each.user.id, {
orgOwned: false,
});
}
// The member manages the file in their own right; the team grant is
// plain read, and is all `bystander` has.
await share(owner.actor, {
uid: file.uuid,
recipient: { username: member.user.username },
mode: 'manage',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { team: team.uid },
mode: 'read',
});
await share(member.actor, {
uid: file.uuid,
recipient: { username: third.user.username },
mode: 'read',
});
expect(await canRead(bystander.actor, file.path)).toBe(true);
await unshare(owner.actor, {
uid: file.uuid,
recipient: { team: team.uid },
});
expect(await canRead(bystander.actor, file.path)).toBe(false);
expect(await canRead(member.actor, file.path)).toBe(true);
expect(await canRead(third.actor, file.path)).toBe(true);
});
it('lets the owner clear a grant a delegate issued', async () => {
const owner = await makeUser();
const delegate = await makeUser();
@@ -2650,12 +2932,13 @@ describe('ShareService', () => {
dirPath = `${dirPath}/${segment}`;
const uuid = uuidv4();
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
uuid,
segment,
dirPath,
owner.user.id,
server.clients.db.booleanValue(true),
now,
parentId,
parentUid,
@@ -2668,12 +2951,13 @@ describe('ShareService', () => {
const uuid = uuidv4();
const filePath = `${dirPath}/state.json`;
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)',
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
uuid,
'state.json',
filePath,
owner.user.id,
server.clients.db.booleanValue(false),
now,
parentId,
parentUid,
@@ -2820,6 +3104,128 @@ describe('ShareService', () => {
expect(listed).toContain(reachable.uuid);
expect(listed).not.toContain(hidden.uuid);
});
it('cannot withdraw a share its user issued outside the app', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const app = await makeApp(owner.user.id);
const file = await makeFile(owner.user);
await share(owner.actor, {
uid: file.uuid,
recipient: { username: recipient.user.username },
mode: 'read',
});
await grantAppReach(owner, app, file);
const result = await unshare(asApp(owner, app), {
uid: file.uuid,
recipient: { username: recipient.user.username },
});
expect(result.revoked).toBe(0);
expect(await canRead(recipient.actor, file.path)).toBe(true);
});
it('cannot withdraw a share a manage delegate issued', async () => {
const owner = await makeUser();
const delegate = await makeUser();
const third = await makeUser();
const app = await makeApp(owner.user.id);
const file = await makeFile(owner.user);
await share(owner.actor, {
uid: file.uuid,
recipient: { username: delegate.user.username },
mode: 'manage',
});
await share(delegate.actor, {
uid: file.uuid,
recipient: { username: third.user.username },
mode: 'read',
});
await grantAppReach(owner, app, file);
const result = await unshare(asApp(owner, app), {
uid: file.uuid,
recipient: { username: third.user.username },
});
expect(result.revoked).toBe(0);
expect(await canRead(third.actor, file.path)).toBe(true);
expect(await canRead(delegate.actor, file.path)).toBe(true);
});
it('cannot cancel an invite its user sent outside the app', async () => {
const owner = await makeUser();
const app = await makeApp(owner.user.id);
const file = await makeFile(owner.user);
const email = `invited-${Math.random()
.toString(36)
.slice(2, 9)}@test.local`;
await share(owner.actor, {
uid: file.uuid,
recipient: { email },
mode: 'read',
});
await grantAppReach(owner, app, file);
const result = await unshare(asApp(owner, app), {
uid: file.uuid,
recipient: { email },
});
expect(result.revoked).toBe(0);
expect(
await server.stores.share.listPendingByEmail(email),
).toHaveLength(1);
});
it('sees only the shares it issued when listing an item', async () => {
const owner = await makeUser();
const mine = await makeUser();
const theirs = await makeUser();
const app = await makeApp(owner.user.id);
const file = await makeFile(owner.user);
const email = `invited-${Math.random()
.toString(36)
.slice(2, 9)}@test.local`;
await grantAppReach(owner, app, file);
await share(asApp(owner, app), {
uid: file.uuid,
recipient: { username: mine.user.username },
mode: 'read',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { username: theirs.user.username },
mode: 'read',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { email },
mode: 'read',
});
const byApp = await runWithContext(
{ actor: asApp(owner, app) },
() =>
server.services.share.listSharesOf(asApp(owner, app), {
uid: file.uuid,
}),
);
expect(byApp.map((s) => s.holder.username)).toEqual([
mine.user.username,
]);
expect(byApp.some((s) => s.recipientEmail)).toBe(false);
// The user's own session still sees all three.
const byOwner = await runWithContext({ actor: owner.actor }, () =>
server.services.share.listSharesOf(owner.actor, {
uid: file.uuid,
}),
);
expect(byOwner).toHaveLength(3);
});
});
// The other derived actor: same reach bound, a different arm of the check.
@@ -4025,6 +4431,44 @@ describe('ShareService', () => {
);
});
it('retires a team share on a descendant when the folder changes owner', async () => {
const attacker = await makeUser();
const victim = await makeUser();
const member = await makeUser();
const { dir, file } = await makeDirWithFile(attacker.user);
const team = await server.stores.team.create({
ownerUserId: attacker.user.id,
name: `t-${Math.random().toString(36).slice(2, 9)}`,
});
await server.stores.team.addMember(team.uid, attacker.user.id, {
orgOwned: false,
});
await server.stores.team.addMember(team.uid, member.user.id, {
orgOwned: false,
});
await share(attacker.actor, {
uid: file.uuid,
recipient: { team: team.uid },
mode: 'read',
});
expect(await canRead(member.actor, file.path)).toBe(true);
await server.stores.fsEntry.updateEntry(dir.uuid, {
userId: victim.user.id,
});
await server.stores.fsEntry.updateEntry(file.uuid, {
userId: victim.user.id,
});
const moved = await server.stores.fsEntry.getEntryByUuid(dir.uuid);
await server.services.share.onEntryOwnerChanged(moved!);
expect(
await server.stores.share.listAllByFsentrySubtree(dir.id),
).toEqual([]);
expect(await canRead(member.actor, file.path)).toBe(false);
});
it('retires a whole burst of deletions in one flush', async () => {
const owner = await makeUser();
const recipient = await makeUser();
@@ -0,0 +1,281 @@
/*
* 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 { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import { createTestUser, setupTestServer } from '../../testUtil.js';
describe('ShareService: deleting a shared item', () => {
let server: PuterServer;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async () => {
const username = `sh${Math.random().toString(36).slice(2, 9)}`;
await createTestUser(server, { username, password: 'pw-test-1234' });
const user = await server.stores.user.getByUsername(username);
if (!user) throw new Error('test user missing');
const email = `${username}@test.local`;
await server.stores.user.update(user.id, {
email,
clean_email: email,
email_confirmed: true,
});
const fresh = await server.stores.user.getById(user.id, {
force: true,
});
const actor: Actor = {
user: fresh as Actor['user'],
effectiveApp: null,
};
return { user: fresh!, actor, email };
};
/** A real fsentry under the user's home, so ancestor chains resolve. */
const makeFile = async (owner: { id: number; username: string }) => {
const uuid = uuidv4();
const name = `f-${uuid.slice(0, 8)}.txt`;
const path = `/${owner.username}/${name}`;
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
[
uuid,
name,
path,
owner.id,
server.clients.db.booleanValue(false),
Math.floor(Date.now() / 1000),
],
);
const entry = await server.stores.fsEntry.getEntryByPath(path);
if (!entry) throw new Error('fsentry not created');
return entry;
};
/**
* A directory and a file inside it, so the file inherits the folder's
* shares.
*/
const makeDirWithFile = async (owner: { id: number; username: string }) => {
const dirUuid = uuidv4();
const dirName = `d-${dirUuid.slice(0, 8)}`;
const dirPath = `/${owner.username}/${dirName}`;
const fileUuid = uuidv4();
const fileName = `f-${fileUuid.slice(0, 8)}.txt`;
const now = Math.floor(Date.now() / 1000);
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)',
[
dirUuid,
dirName,
dirPath,
owner.id,
server.clients.db.booleanValue(true),
now,
],
);
const dirRow = await server.stores.fsEntry.getEntryByPath(dirPath);
await server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
fileUuid,
fileName,
`${dirPath}/${fileName}`,
owner.id,
server.clients.db.booleanValue(false),
now,
dirRow!.id,
dirUuid,
],
);
const dir = await server.stores.fsEntry.getEntryByPath(dirPath);
const file = await server.stores.fsEntry.getEntryByPath(
`${dirPath}/${fileName}`,
);
if (!dir || !file) throw new Error('fsentries not created');
return { dir, file };
};
const check = (actor: Actor, path: string, mode: 'read' | 'write') =>
server.services.acl.check(
actor,
{
path,
resolveAncestors: () =>
server.services.fs.getAncestorChain(path),
},
mode,
);
const canRead = (actor: Actor, path: string) => check(actor, path, 'read');
const canWrite = (actor: Actor, path: string) =>
check(actor, path, 'write');
const share = (actor: Actor, input: Record<string, unknown>) =>
runWithContext({ actor }, () =>
server.services.share.share(actor, input as never),
);
const dirAt = async (path: string) => {
const entry = await server.stores.fsEntry.getEntryByPath(path, {
skipCache: true,
});
if (!entry) throw new Error(`missing directory: ${path}`);
return entry;
};
/** The move a GUI delete performs: into the owner's own Trash. */
const move = (
owner: { user: { id: number }; actor: Actor },
source: FSEntry,
destinationParent: FSEntry,
) =>
runWithContext({ actor: owner.actor }, () =>
server.services.fs.move(owner.user.id, {
source,
destinationParent,
}),
);
it('withdraws every share on a folder the owner deletes', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const second = await makeUser();
const { dir, file } = await makeDirWithFile(owner.user);
await share(owner.actor, {
uid: dir.uuid,
recipient: { username: recipient.user.username },
mode: 'write',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { username: second.user.username },
mode: 'read',
});
expect(await canWrite(recipient.actor, file.path)).toBe(true);
expect(await canRead(second.actor, file.path)).toBe(true);
const trash = await dirAt(`/${owner.user.username}/Trash`);
const moved = await move(owner, dir, trash);
expect(await canRead(recipient.actor, moved.path)).toBe(false);
expect(
await canWrite(recipient.actor, `${moved.path}/${file.name}`),
).toBe(false);
expect(await canRead(second.actor, `${moved.path}/${file.name}`)).toBe(
false,
);
expect(
await server.services.share.listSharesOf(owner.actor, {
uid: dir.uuid,
}),
).toEqual([]);
expect(await server.stores.share.listByFsentry(dir.id)).toEqual([]);
expect(await server.stores.share.listByFsentry(file.id)).toEqual([]);
});
it('drops a link share and an unclaimed invite inside it', async () => {
const owner = await makeUser();
const { dir, file } = await makeDirWithFile(owner.user);
const email = `pending-${Math.random().toString(36).slice(2, 9)}@test.local`;
await server.stores.share.upsertAnyone({
issuerUserId: owner.user.id,
fsentryId: dir.id,
mode: 'read',
});
await share(owner.actor, {
uid: file.uuid,
recipient: { email },
mode: 'read',
});
expect(await server.stores.share.getAnyone(dir.id)).not.toBeNull();
expect(
await server.stores.share.listPendingOnFsentry(file.id),
).toHaveLength(1);
const trash = await dirAt(`/${owner.user.username}/Trash`);
await move(owner, dir, trash);
expect(await server.stores.share.getAnyone(dir.id)).toBeNull();
expect(await server.stores.share.listPendingOnFsentry(file.id)).toEqual(
[],
);
});
it('does not hand access back when the item leaves Trash', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const { dir, file } = await makeDirWithFile(owner.user);
await share(owner.actor, {
uid: dir.uuid,
recipient: { username: recipient.user.username },
mode: 'write',
});
const trash = await dirAt(`/${owner.user.username}/Trash`);
const trashed = await move(owner, dir, trash);
const home = await dirAt(`/${owner.user.username}`);
const restored = await move(owner, trashed, home);
expect(await canRead(recipient.actor, restored.path)).toBe(false);
expect(
await canWrite(recipient.actor, `${restored.path}/${file.name}`),
).toBe(false);
expect(
await server.services.share.listSharesOf(owner.actor, {
uid: dir.uuid,
}),
).toEqual([]);
});
it('leaves the shares alone on an ordinary move', async () => {
const owner = await makeUser();
const recipient = await makeUser();
const { dir, file } = await makeDirWithFile(owner.user);
await share(owner.actor, {
uid: dir.uuid,
recipient: { username: recipient.user.username },
mode: 'write',
});
const documents = await dirAt(`/${owner.user.username}/Documents`);
const moved = await move(owner, dir, documents);
expect(await canWrite(recipient.actor, moved.path)).toBe(true);
expect(
await canWrite(recipient.actor, `${moved.path}/${file.name}`),
).toBe(true);
expect(await server.stores.share.listByFsentry(dir.id)).toHaveLength(1);
});
});
+218 -63
View File
@@ -1252,13 +1252,16 @@ export class ShareService extends PuterService {
/**
* Withdraw a recipient's access. An owner may clear any issuer's share of
* their node; anyone else may only clear the ones they issued.
* their node; anyone else may only clear the ones they issued. An app
* credential is narrower than the user behind it: only the shares that app
* issued, whatever authority its user has over the rest.
*/
async unshare(
actor: Actor,
input: ShareTarget & { recipient: ShareRecipient },
): Promise<{ revoked: number }> {
const issuerId = this.#requireUserId(actor);
const actingApp = this.#actingAppUid(actor);
const [entry, resolved] = await Promise.all([
this.#resolveEntry(input, actor),
this.#resolveRecipient(input.recipient),
@@ -1266,14 +1269,19 @@ export class ShareService extends PuterService {
if (resolved.kind === 'anyone') {
await this.#assertCanManage(actor, entry);
this.#assertOwnsLinkShare(entry, issuerId);
this.#assertOwnsLinkShare(actor, entry, issuerId);
const removed = await this.stores.share.deleteAnyone(entry.id);
return { revoked: removed ? 1 : 0 };
}
// Nothing was granted, so there is only the invitation to take back.
if (resolved.kind === 'pending') {
await this.#assertCanManage(actor, entry);
return this.#cancelInvite(entry, resolved.email, issuerId);
return this.#cancelInvite(
entry,
resolved.email,
issuerId,
actingApp,
);
}
if (resolved.kind === 'team') {
await this.#assertCanManage(actor, entry);
@@ -1297,14 +1305,24 @@ export class ShareService extends PuterService {
}
// An owner may clear any issuer's share of their node; anyone else may
// clear the ones they issued, or their own access.
// clear the ones they issued, or their own access. An app is held to
// the rows it issued itself, the same bound `revokeSharedByMe` puts on
// a row addressed by uid — dropping the user's own access is still
// theirs to drop.
const isOwner = entry.userId === issuerId;
const appScoped = Boolean(actingApp) && !isLeaving;
const rows = (await this.stores.share.listByFsentry(entry.id)).filter(
(row: { holder_user_id: number; issuer_user_id: number }) =>
(row: ShareIndexRow) =>
row.holder_user_id === holder.id &&
(isOwner || isLeaving || row.issuer_user_id === issuerId),
(isOwner || isLeaving || row.issuer_user_id === issuerId) &&
(!appScoped || issuedByApp(row) === actingApp),
);
// The index is the only record of which app issued what, so an app
// with no row of its own here has nothing to take back — and must not
// reach the grant fallback below, which is not attributed to any app.
if (appScoped && rows.length === 0) return { revoked: 0 };
// Fall back to the live grants when no index row exists — a grant may
// predate the index, and a revoke must still work. Reading them is what
// makes "leave this share" work at all: the issuer there is whoever
@@ -1345,9 +1363,20 @@ export class ShareService extends PuterService {
): Promise<{ revoked: number }> {
// Whatever the holder re-shared goes with them, and this has to run
// first: when the holder is the actor, clearing their own grants would
// strip the very `manage` the cascade needs to do it.
// strip the very `manage` the cascade needs to do it. Unless the
// holder keeps `manage` from outside this revoke, in which case what
// they granted never rested on it.
const writer = userRelatedActor(actor);
let revoked = await this.#revokeDownstream(writer, entry, holder.id);
const keepsAuthority = await this.#managedFromOutside(
entry,
holder.id,
{
issuers: new Set(issuers),
},
);
let revoked = keepsAuthority
? 0
: await this.#revokeDownstream(writer, entry, holder.id);
for (const issuer of issuers) {
const { revoked: didRevoke, authorized } = await this.#revokeFor(
@@ -1368,6 +1397,56 @@ export class ShareService extends PuterService {
return { revoked };
}
/**
* Whether `holderId` would still hold `manage` here once the revoke in
* `scope` has run — from an issuer it does not reach, another team's grant,
* or a folder above. Read before anything is withdrawn, so the answer is
* not confused by the revoke's own effect.
*
* What the holder re-shared rests on that authority, so while any of it
* stands the re-shares are nobody's to cascade away.
*/
async #managedFromOutside(
entry: FSEntry,
holderId: number,
scope: { issuers?: Set<number>; groupId?: number },
): Promise<boolean> {
const managePerm = entryPermissionForMode(
entry.uuid,
MANAGE_PERM_PREFIX,
);
const [linked, viaGroup] = await Promise.all([
this.stores.permission.readLinkedUserUserPerms(holderId, [
managePerm,
]),
this.stores.permission.readUserGroupPerms(holderId, [managePerm]),
]);
if (
linked.some(
(row) => !scope.issuers?.has(Number(row.issuer_user_id)),
)
) {
return true;
}
if (viaGroup.some((row) => Number(row.group_id) !== scope.groupId)) {
return true;
}
// `manage` inherits downwards, so a grant on a folder above outlives
// anything withdrawn on this node. Asked about the parent rather than
// the node itself, or the grants just ruled out would answer it.
const [parent] = (
await this.services.fs.getAncestorChain(entry.path)
).slice(1);
if (!parent) return false;
const holder = await this.stores.user.getById(holderId);
if (!holder) return false;
return this.services.permission.canManagePermission(
this.#actorFor(holder),
entryPermissionForMode(parent.uid, 'read'),
);
}
/**
* Withdraw everything `issuerId` granted on this node, and everything those
* recipients granted in turn.
@@ -1554,10 +1633,14 @@ export class ShareService extends PuterService {
* Scoped by the share index rather than by walking the subtree: the work is
* bounded by how many shares exist under the node (usually none), not by
* how many files it holds, and the recursive walk rides `parent_id`.
*
* Every kind of row, holders or not: a link share or a team grant deeper in
* the subtree stands on its own, and one left behind keeps granting the
* issuer's recipients access under an owner who never agreed to them.
*/
async onEntryOwnerChanged(entry: FSEntry): Promise<void> {
const rows = entry.isDir
? await this.stores.share.listByFsentrySubtree(entry.id)
? await this.stores.share.listAllByFsentrySubtree(entry.id)
: await this.stores.share.listByFsentry(entry.id);
const fsentryIds = [
...new Set([
@@ -1574,13 +1657,41 @@ export class ShareService extends PuterService {
await this.stores.share.deleteByFsentryIds(fsentryIds);
}
/**
* Withdraw every share on a node the owner has just deleted, and on
* everything inside it.
*
* Trashing only moves the entry, so nothing cascades: grants name a uuid
* and the ACL walks ancestors by uuid, which left a recipient reading and
* writing an item the owner considers gone. Link shares and unclaimed
* invites go with them, and a restore does not bring any of it back.
*/
async onEntryTrashed(entry: FSEntry): Promise<void> {
// Every kind of row, since a link share deeper in the subtree grants
// access on its own, without a grant or an index row above it.
const rows = await this.stores.share.listAllByFsentrySubtree(entry.id);
const fsentryIds = [
...new Set([
entry.id,
...rows.map((row: { fsentry_id: number }) =>
Number(row.fsentry_id),
),
]),
].filter((id) => Number.isFinite(id));
const nodes = await this.stores.fsEntry.getEntriesByIds(fsentryIds);
const uuids = [...nodes.values()].map((node) => node.uuid);
if (uuids.length > 0) await this.onEntryDeleted(uuids);
await this.stores.share.deleteByFsentryIds(fsentryIds);
}
// -- Reads --------------------------------------------------------
/**
* What has been shared with `actor`, newest page first by id. Entries are
* hydrated in one batch; rows whose entry is gone, or which resolve into
* the owner's trash, are dropped — the share survives a trashing so a
* restore is lossless, it just shouldn't be listed.
* the owner's trash, are dropped. Trashing withdraws the shares on an item,
* so the trash filter only catches rows written before that was true.
*/
async listSharedWithMe(
actor: Actor,
@@ -1770,9 +1881,10 @@ export class ShareService extends PuterService {
if (anyone) {
// Silent while the plan is lapsed, so not listed either.
if (!linkCovered) continue;
// A node that changed hands had its rows dropped, so this only
// catches a row that slipped.
if (entry.userId !== Number(row.issuer_user_id)) continue;
// A link the caller issued on a node that has since changed
// hands is no longer theirs to see. One *they* own that another
// issuer left behind is, or they cannot find it to remove it.
if (entry.userId !== userId) continue;
} else if (row.holder_group_id) {
if (!liveGroupGrants.has(row.uid)) continue;
} else if (
@@ -1918,7 +2030,7 @@ export class ShareService extends PuterService {
// it back — the same bound as setting it.
if (row.anyone) {
await this.#assertCanManage(actor, entry);
this.#assertOwnsLinkShare(entry, userId);
this.#assertOwnsLinkShare(actor, entry, userId);
const removed = await this.stores.share.deleteAnyone(entry.id);
return { revoked: removed ? 1 : 0 };
}
@@ -2090,34 +2202,50 @@ export class ShareService extends PuterService {
const nodeById = new Map(
[entry, ...ancestorNodes.values()].map((node) => [node.id, node]),
);
const inherited: Array<{ row: ShareIndexRow; via: string }> = (
await this.stores.share.listByFsentries([...viaById.keys()])
).map((row: ShareIndexRow) => ({
row,
via: viaById.get(Number(row.fsentry_id)) as string,
}));
// An app credential sees only what it issued itself, the bound
// `listSharedByMe` already puts on the flat listing: whoever else
// reaches the node — another app, a delegate, an address the user
// invited — is the user's business, not the app's.
const actingApp = this.#actingAppUid(actor);
const issuedHere = <T extends { data?: unknown }>(list: T[]): T[] =>
actingApp
? list.filter((row) => issuedByApp(row) === actingApp)
: list;
const rows = await this.stores.share.listByFsentry(entry.id);
const pendingRows = await this.stores.share.listPendingOnFsentry(
entry.id,
const inherited: Array<{ row: ShareIndexRow; via: string }> =
issuedHere(
await this.stores.share.listByFsentries([...viaById.keys()]),
).map((row: ShareIndexRow) => ({
row,
via: viaById.get(Number(row.fsentry_id)) as string,
}));
const rows = issuedHere(
await this.stores.share.listByFsentry(entry.id),
);
const pendingRows = issuedHere(
await this.stores.share.listPendingOnFsentry(entry.id),
);
// Ancestors too: a team can reach this through a folder above it.
const groupRows = (
await Promise.all(
[entry.id, ...viaById.keys()].map((id) =>
this.stores.share.listGroupOnFsentry(id),
),
)
).flat();
const groupRows = issuedHere(
(
await Promise.all(
[entry.id, ...viaById.keys()].map((id) =>
this.stores.share.listGroupOnFsentry(id),
),
)
).flat(),
);
// And so can anyone with the link to a folder above it — while the
// owner's plan covers it. A link the ACL turns away is not a share the
// owner should see listed as one; it is silent, and stays silent until
// the plan is back.
let anyoneRows: OutboundShareRow[] =
let anyoneRows: OutboundShareRow[] = issuedHere(
await this.stores.share.listAnyoneOnFsentries([
entry.id,
...viaById.keys(),
]);
]),
);
if (anyoneRows.length > 0 && !(await this.#linkSharingCovered(entry))) {
anyoneRows = [];
}
@@ -2522,20 +2650,23 @@ export class ShareService extends PuterService {
/**
* Withdraw an invite before it is claimed. An owner may clear any issuer's
* invite on their node; anyone else only the ones they sent.
* invite on their node; anyone else only the ones they sent, and an app
* only the ones it sent itself.
*/
async #cancelInvite(
entry: FSEntry,
email: string,
issuerId: number,
actingApp: string | null = null,
): Promise<{ revoked: number }> {
const isOwner = entry.userId === issuerId;
const rows = (
await this.stores.share.listPendingByEmail(cleanEmail(email))
).filter(
(row: { fsentry_id: number; issuer_user_id: number }) =>
(row: OutboundShareRow) =>
Number(row.fsentry_id) === entry.id &&
(isOwner || Number(row.issuer_user_id) === issuerId),
(isOwner || Number(row.issuer_user_id) === issuerId) &&
(!actingApp || issuedByApp(row) === actingApp),
);
let revoked = 0;
for (const row of rows) {
@@ -2578,7 +2709,7 @@ export class ShareService extends PuterService {
* covers link sharing — which is why the plan is checked here as well: a
* lapsed plan silences an existing link, and a free account never gets to
* mint one. Owner-only, since it opens the node to every account, a say a
* `manage` delegate was never given.
* `manage` delegate — or an app acting for the owner — was never given.
*/
async #shareWithAnyone(
actor: Actor,
@@ -2593,7 +2724,7 @@ export class ShareService extends PuterService {
{ legacyCode: 'invalid_mode' },
);
}
this.#assertOwnsLinkShare(entry, issuerId);
this.#assertOwnsLinkShare(actor, entry, issuerId);
await assertActorHasSubscription(
this.services.metering,
actor,
@@ -2646,8 +2777,20 @@ export class ShareService extends PuterService {
);
}
/** Link sharing is the owner's call, on and off alike. */
#assertOwnsLinkShare(entry: FSEntry, userId: number): void {
/**
* Link sharing is the owner's call, on and off alike — the owner in person.
* An app acts with its user's authority but this is not access to hand on:
* it opens the node to every account, and the app was given reach to one
* item, not a say over who else may have it.
*/
#assertOwnsLinkShare(actor: Actor, entry: FSEntry, userId: number): void {
if (actor.effectiveApp) {
throw new HttpError(
403,
'An app cannot share with anyone with the link',
{ legacyCode: 'forbidden' },
);
}
if (entry.userId === userId) return;
throw new HttpError(
403,
@@ -2719,7 +2862,11 @@ export class ShareService extends PuterService {
}
}
/** An owner clears any issuer's grant; anyone else only their own. */
/**
* An owner clears any issuer's grant; anyone else only their own, and an
* app only the ones it issued itself. A row addressed by uid arrives as
* `onlyIssuer`, already bounded by its caller.
*/
async #unshareTeam(
actor: Actor,
issuerId: number,
@@ -2728,28 +2875,27 @@ export class ShareService extends PuterService {
onlyIssuer?: number,
): Promise<{ revoked: number }> {
const isOwner = entry.userId === issuerId;
const issuers =
onlyIssuer !== undefined
? [onlyIssuer]
: isOwner
? [
...new Set(
(
await this.stores.share.listGroupSharesByFsentry(
entry.id,
)
)
.filter(
(row: { holder_group_id: number }) =>
Number(row.holder_group_id) === team.id,
)
.map((row: { issuer_user_id: number }) =>
Number(row.issuer_user_id),
),
),
issuerId,
]
: [issuerId];
const actingApp =
onlyIssuer === undefined ? this.#actingAppUid(actor) : null;
const indexedIssuers = async (): Promise<number[]> => [
...new Set(
(await this.stores.share.listGroupSharesByFsentry(entry.id))
.filter(
(row: OutboundShareRow) =>
Number(row.holder_group_id) === team.id &&
(!actingApp || issuedByApp(row) === actingApp),
)
.map((row: OutboundShareRow) => Number(row.issuer_user_id)),
),
];
let issuers: number[];
if (onlyIssuer !== undefined) issuers = [onlyIssuer];
else if (actingApp) issuers = await indexedIssuers();
else if (isOwner) issuers = [...(await indexedIssuers()), issuerId];
else issuers = [issuerId];
// Nothing of the app's own to withdraw, so nothing downstream either.
if (issuers.length === 0) return { revoked: 0 };
// Authority is the caller's throughout, as on the user-to-user path:
// impersonating a delegate who has since lost it throws 403 and aborts
@@ -2768,6 +2914,15 @@ export class ShareService extends PuterService {
// The owner's own grants do not derive from this one, and an
// issuer's are handled by the revoke loop below.
if (memberId === entry.userId || issuerSet.has(memberId)) continue;
// Nor does what a member re-shared on `manage` held elsewhere —
// another person's grant, another team's, or a folder above.
if (
await this.#managedFromOutside(entry, memberId, {
groupId: team.id,
})
) {
continue;
}
revoked += await this.#revokeDownstream(me, entry, memberId);
}
+24
View File
@@ -322,6 +322,30 @@ export class ShareStore extends PuterStore {
return rows.map((r) => this.#normalizeRow(r));
}
/**
* Every share row on a node and everything beneath it, whatever kind —
* user, group and link shares plus unclaimed invites.
* `listByFsentrySubtree` answers the narrower question; this one is for
* retiring the lot.
*
* @param {number} fsentryId
*/
async listAllByFsentrySubtree(fsentryId) {
const rows = await this.clients.db.read(
'WITH RECURSIVE `subtree`(`id`) AS (' +
'SELECT `id` FROM `fsentries` WHERE `id` = ? ' +
'UNION ALL ' +
'SELECT `f`.`id` FROM `fsentries` `f` ' +
'JOIN `subtree` `s` ON `f`.`parent_id` = `s`.`id`' +
') ' +
'SELECT `share`.* FROM `share` ' +
'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' +
'ORDER BY `share`.`id`',
[fsentryId],
);
return rows.map((r) => this.#normalizeRow(r));
}
/**
* Active shares on any of `fsentryIds` — a node plus its ancestors, which
* the caller has already resolved to row ids.
+1
View File
@@ -314,6 +314,7 @@ These cloud storage features are supported out of the box when using Puter.js:
- **[`puter.fs.listShared()`](/FS/listShared/)** - List what others have shared with you
- **[`puter.fs.listSharedByMe()`](/FS/listSharedByMe/)** - List everything you have shared out
- **[`puter.fs.getShares()`](/FS/getShares/)** - List who has access to an item
- **[`puter.fs.getShareLink()`](/FS/getShareLink/)** - Build a link that opens a file in an app
## Examples
+4
View File
@@ -33,6 +33,10 @@ The options for the `delete` operation. The following options are supported:
A `Promise` that will resolve when the file or directory is deleted.
Deleting an item withdraws every [share](/FS/share/) on it and on everything
inside it, links and unclaimed invites included. Moving an item to Trash counts
as deleting it here, and restoring it does not bring the shares back.
## Examples
<strong class="example-title">Delete a file</strong>
+88
View File
@@ -0,0 +1,88 @@
---
title: puter.fs.getShareLink()
description: Build a link that opens a file in an app on Puter, for the people the file is shared with.
platforms: [websites, apps, nodejs, workers]
---
Builds the link that opens a file in an app on Puter, of the form `https://puter.com/app/<appName>?file=<uid>`. Send it to whoever the file is shared with: following it signs them in if needed, then opens the app with the file — after Puter has asked them to allow the app to open it.
The link carries no access of its own. It works for the file's owner, for anyone the file was shared with through [`share()`](/FS/share/), and for any signed-in account once the file is open to **anyone with the link**. For everyone else the file is not found, and the app opens without it.
## Syntax
```js
puter.fs.getShareLink(item)
puter.fs.getShareLink(item, appName)
puter.fs.getShareLink(options)
```
## Parameters
#### `item` (String) (required)
The file, as a path or a UID. If a path is not absolute, it is resolved relative to the app's root directory. Directories are refused: a link opens one file in one app.
#### `appName` (String) (optional)
The name of the app the link opens the file with. Defaults to the app the code is running in; outside a Puter app it is required.
#### `options` (Object) (optional)
An object with the following properties:
- `path` (String) - The file. Required when passing options as the only argument, unless `uid` is given.
- `uid` (String) - The file, by UID. Can be used instead of `path`.
- `appName` (String) - As above.
## Return value
A `Promise` that resolves to the link, as a string. It is built on the file's UID, so renaming or moving the file does not break it.
## Errors
| `code` | Meaning |
| --- | --- |
| `field_missing` | Neither a path nor a UID was given. |
| `app_name_required` | No `appName` was given, and the code is not running inside a Puter app. |
| `not_a_file` | The item is a directory. |
A file that does not exist, or that you cannot see, rejects the way [`stat()`](/FS/stat/) does.
## What the recipient sees
The app opens on the recipient's Puter, and before it is handed the file Puter shows them what is being asked — which app, which file — and lets them refuse. Refused, the app still opens, just without the file. The app receives the access the recipient has: a file shared read-only opens read-only.
## Examples
<strong class="example-title">Open a shared file in an app</strong>
```html;fs-getShareLink
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
await puter.fs.write('notes.txt', 'Meeting notes');
// Anyone signed in who has the link may read it (paid plans).
await puter.fs.share('notes.txt', { anyone: true }, 'read');
const link = await puter.fs.getShareLink('notes.txt', 'editor');
puter.print(`Open in Editor: <a href="${link}" target="_blank">${link}</a>`);
})()
</script>
</body>
</html>
```
<strong class="example-title">Send a collaborator straight into your app</strong>
```js
// Inside a Puter app, the link opens the file in this app.
await puter.fs.share('draft.md', 'friend@example.com', 'write');
const link = await puter.fs.getShareLink('draft.md');
```
## Related
- [`puter.fs.share()`](/FS/share/) - Grant access, or open the file to anyone with the link
- [`puter.fs.getReadURL()`](/FS/getReadURL/) - A URL that reads the file's bytes without signing in
+5 -1
View File
@@ -12,7 +12,9 @@ This method lists who can reach a file or directory you own, or one you have `ma
> read, and nothing more. Files its user owns but never handed to the app stay
> out of reach, and `listShared()` shows an app only the shares it can reach.
> Shares an app creates are attributed to the user and carry `issuedByApp`, so
> the owner can tell them apart in [`getShares()`](/FS/getShares/).
> the owner can tell them apart in [`getShares()`](/FS/getShares/) — and those
> are the only ones an app can list or withdraw on an item. Opening an item to
> anyone with the link is the owner's own call, never an app's.
## Syntax
@@ -44,6 +46,8 @@ A `Promise` that resolves to an array of share objects, each with `uid`, `mode`,
The list includes shares granted by **anyone** holding `manage` on the item, not only your own. That is how an owner sees what someone they trusted has re-shared.
That is the view from your own session. An **app** asking on your behalf is shown only the shares that app issued — another app's rows, a delegate's, and the addresses you invited are not its business, whatever reach you gave it on the item.
If the item is open to **anyone with the link** (see [`share()`](/FS/share/)), that share is listed too, with `anyone: true` and a `null` `holder` — inherited from a folder above when the folder is what was opened. It is left out while the owner's plan does not cover link sharing, because nobody can use it then.
It also includes **invitations** — shares aimed at an email address with no confirmed account yet. Those carry `pending: true`, a `null` `holder`, and the address in `recipientEmail`. They grant nothing until the recipient confirms that address, and [`unshare()`](/FS/unshare/) cancels one before it is claimed.
+3 -1
View File
@@ -12,7 +12,9 @@ This method lists what other Puter users have shared with you, a page at a time.
> read, and nothing more. Files its user owns but never handed to the app stay
> out of reach, and `listShared()` shows an app only the shares it can reach.
> Shares an app creates are attributed to the user and carry `issuedByApp`, so
> the owner can tell them apart in [`getShares()`](/FS/getShares/).
> the owner can tell them apart in [`getShares()`](/FS/getShares/) — and those
> are the only ones an app can list or withdraw on an item. Opening an item to
> anyone with the link is the owner's own call, never an app's.
## Syntax
+17 -7
View File
@@ -12,7 +12,9 @@ This method gives another Puter user access to a file or directory you own, or o
> read, and nothing more. Files its user owns but never handed to the app stay
> out of reach, and `listShared()` shows an app only the shares it can reach.
> Shares an app creates are attributed to the user and carry `issuedByApp`, so
> the owner can tell them apart in [`getShares()`](/FS/getShares/).
> the owner can tell them apart in [`getShares()`](/FS/getShares/) — and those
> are the only ones an app can list or withdraw on an item. Opening an item to
> anyone with the link is the owner's own call, never an app's.
## Syntax
@@ -89,7 +91,7 @@ One refusal applies to the whole call instead: handing out access requires a ver
| `code` | Meaning |
| --- | --- |
| `subject_does_not_exist` | No such item, or you cannot see it. Also what a caller without permission to share gets, so the response never reveals which. |
| `forbidden` | You can see the item but may not share it at the level you asked for — or you asked to open it to anyone with the link, which only its owner may do (or undo). |
| `forbidden` | You can see the item but may not share it at the level you asked for — or you asked to open it to anyone with the link, which only its owner may do (or undo), in person rather than through an app. |
| `user_does_not_exist` | The username has no account. (An unknown *email* is invited instead — see below.) |
| `recipient_not_accepting_shares` | The recipient is not accepting this share — they have blocked you, or turned off new shares from everyone. Nothing is granted and they are not notified. Which of the two it is is not reported. |
| `email_not_allowed` | The address can't receive an invite — malformed, or refused by the deployment's policy. |
@@ -102,7 +104,7 @@ One refusal applies to the whole call instead: handing out access requires a ver
## Sharing with anyone with the link
`{ anyone: true }` opens the item to **every signed-in Puter account** that can name it — by the link the desktop offers, or by its path. It takes `read` or `write`, never `manage`: a link hands out access, not the authority to share onward. An item inside a folder opened this way is reachable the same way.
`{ anyone: true }` opens the item to **every signed-in Puter account** that can name it — by the link the desktop offers, by one built with [`getShareLink()`](/FS/getShareLink/), or by its path. It takes `read` or `write`, never `manage`: a link hands out access, not the authority to share onward. An item inside a folder opened this way is reachable the same way.
```js
// Anyone signed in who has the link can read it.
@@ -116,7 +118,7 @@ await puter.fs.unshare('report.txt', { anyone: true });
Three things set it apart from sharing with a person:
- **It is the owner's call.** Someone holding `manage` on the item can share it with people, but not open it to everyone; they get `forbidden`.
- **It is the owner's call, in person.** Someone holding `manage` on the item can share it with people, but not open it to everyone; they get `forbidden`. So does an app acting for the owner, at any level of reach it was given: it can hand the item to a named recipient, but opening it to every account is not access to pass on. Closing the link is bounded the same way.
- **It is a paid-plan feature.** A free account is refused with `subscription_required`. The plan is checked again every time the link is used, so while the owner has no plan the link is silent — nobody has to find it and take it back — and it is not listed as a share by [`getShares()`](/FS/getShares/) or [`listSharedByMe()`](/FS/listSharedByMe/) either, since nobody can use it. The share itself is kept: once the owner is on a plan again the link works, and is listed, exactly as it was.
- **Nobody is told.** No notification goes out, and the item does not appear in anyone's [`listShared()`](/FS/listShared/); whoever has the link opens it from the link.
@@ -191,7 +193,7 @@ when freshness matters, for example on focus or an explicit refresh.
## What sharing does not promise
Three things are worth knowing before you share something sensitive.
A few things are worth knowing before you share something sensitive.
**A signed URL outlives the share.** Anyone who can read a shared item can
mint a signed URL for it, and that URL is a bearer token: it works for whoever
@@ -204,8 +206,11 @@ on, what you gave them.
your name, so an app acting for you can share the items it can already reach —
its own AppData, and whatever you handed it — with anyone, and at any level it
holds itself. It cannot reach past that into the rest of your files. Shares an
app issued are marked with `issued_by_app` in
[`getShares()`](/FS/getShares/), so you can tell them apart from your own.
app issued are marked with `issuedByApp` in
[`getShares()`](/FS/getShares/), so you can tell them apart from your own — and
they bound what it can undo: the shares you made yourself, the ones another app
made, and the ones a `manage` delegate made are not an app's to list or take
back, and none of them is an app's to open to anyone with the link.
**A link is a bearer credential, with a sign-in.** An item open to anyone with
the link is open to any account that can name it, including a temporary one
@@ -219,6 +224,11 @@ give, and it is no longer yours. The same applies in reverse: files a recipient
creates inside a folder you shared belong to you and count against your
storage.
**Deleting a shared item withdraws its shares.** Deleting moves the item to
Trash, and every share on it and on everything inside it — people, teams, links
and unclaimed invites alike — is withdrawn at that moment. Restoring it from
Trash does not bring them back; share it again.
## Related
- [`puter.fs.unshare()`](/FS/unshare/) - Withdraw access
+6 -3
View File
@@ -12,7 +12,9 @@ This method withdraws a user's access to a file or directory.
> read, and nothing more. Files its user owns but never handed to the app stay
> out of reach, and `listShared()` shows an app only the shares it can reach.
> Shares an app creates are attributed to the user and carry `issuedByApp`, so
> the owner can tell them apart in [`getShares()`](/FS/getShares/).
> the owner can tell them apart in [`getShares()`](/FS/getShares/) — and those
> are the only ones an app can list or withdraw on an item. Opening an item to
> anyone with the link is the owner's own call, never an app's.
## Syntax
@@ -31,7 +33,7 @@ The path to the file or directory. If `path` is not absolute, it will be resolve
Whose access to withdraw. A string containing `@` is treated as an email address, and any other string as a username.
Pass **yourself** to leave a share someone else gave you. Pass `{ team: uid }` to withdraw a team's access, or `{ anyone: true }` to stop sharing with anyone with the link — the latter is the owner's call, as opening it was.
Pass **yourself** to leave a share someone else gave you. Pass `{ team: uid }` to withdraw a team's access, or `{ anyone: true }` to stop sharing with anyone with the link — the latter is the owner's call, in person, as opening it was.
#### `options` (Object) (optional)
@@ -49,11 +51,12 @@ A `Promise` that resolves to `{ revoked }`, where `revoked` is how many grants w
- The item's **owner** can withdraw any share of it, whoever granted it.
- Anyone else can withdraw the shares **they** granted.
- An **app** acting for you withdraws only the shares that app issued, whatever authority you have over the rest: your own shares, another app's and a delegate's all report `revoked: 0` to it. Leaving a share yourself still works through an app, since that is your own access to drop.
- **Anyone** can withdraw their own access, whoever granted it.
An item's owner cannot be removed from their own item.
Withdrawing someone's access also withdraws whatever **they** re-shared of that item. Their authority to grant came from the access being removed, so it cannot outlive it.
Withdrawing someone's access also withdraws whatever **they** re-shared of that item — unless their authority to grant does not rest on what you took back. Someone who still holds `manage` here from another person, from a team, or from a folder above keeps what they granted; it was never yours to withdraw.
Passing an email address that was **invited** but has not yet joined cancels the invitation. Nothing was granted, so nothing is revoked from anyone — the pending share simply stops waiting.
+1 -1
View File
@@ -23,7 +23,7 @@ A string containing the name of the subdomain you want to create.
A string containing the path to the directory you want to serve.
The directory must be one you own. Hosting serves everything under it publicly, including files added later, so a directory someone shared with you can only be published if they gave you `manage` access — [`share()`](/FS/share/) calls that level "Can edit & share".
The directory must be one you own. Hosting serves everything under it publicly, including files added later, so a directory someone shared with you can only be published if they gave you `manage` access — [`share()`](/FS/share/) calls that level "Can edit & share". That access is also what keeps the site up: if the owner withdraws it, or moves the directory to their trash, the site stops being served.
#### `options` (Object) (optional)
+6
View File
@@ -432,6 +432,12 @@ const examples = [
slug: 'fs-getShares',
source: '/playground/examples/fs-getShares.html',
},
{
title: 'Link to a file in an app',
description: 'Build a link that opens a shared file in an app with Puter.js filesystem API. Run and modify this example in your browser.',
slug: 'fs-getShareLink',
source: '/playground/examples/fs-getShareLink.html',
},
],
},
{
@@ -0,0 +1,15 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
await puter.fs.write('notes.txt', 'Meeting notes');
// Anyone signed in who has the link may read it (paid plans).
await puter.fs.share('notes.txt', { anyone: true }, 'read');
const link = await puter.fs.getShareLink('notes.txt', 'editor');
puter.print(`Open in Editor: <a href="${link}" target="_blank">${link}</a>`);
})()
</script>
</body>
</html>
+8
View File
@@ -406,6 +406,14 @@ let sidebar = [
source: '/FS/getShares.md',
path: '/FS/getShares',
},
{
title: '<code>getShareLink()</code>',
page_title: '<code>puter.fs.getShareLink()</code>',
title_tag: 'puter.fs.getShareLink()',
icon: '/assets/img/function.svg',
source: '/FS/getShareLink.md',
path: '/FS/getShareLink',
},
],
},
{
+3 -3
View File
@@ -40,6 +40,7 @@ import truncate_filename from '../helpers/truncateFilename.js';
import UINotification from './UINotification.js';
import UIWindowWelcome from './UIWindowWelcome.js';
import launch_app from '../helpers/launchApp.js';
import { urlFileLaunchOptions } from '../helpers/confirmUrlFileAccess.js';
import item_icon from '../helpers/itemIcon.js';
import { SHARED_PATH_PARAM, clear_shared_param } from '../helpers/parseSharedPath.js';
import resolve_shared_item from '../helpers/resolveSharedItem.js';
@@ -1365,17 +1366,16 @@ async function UIDesktop (options) {
if ( window.app_query_params && window.app_query_params.posargs ) {
posargs = JSON.parse(window.app_query_params.posargs);
}
// `?file=<path>` opens that file with the app, the same as
// `?file=<path or uid>` opens that file with the app, the same as
// double-clicking it would — but the link picked both, so the user
// is asked before the app is given the file.
const file_path = window.url_query_params.get('file');
launch_app({
app: window.app_launched_from_url.name,
app_obj: window.app_launched_from_url,
readURL: window.url_query_params.get('readURL'),
maximized: window.url_query_params.get('maximized'),
params: window.app_query_params ?? [],
...(file_path ? { file_path, confirm_file_access: true } : {}),
...urlFileLaunchOptions(window.url_query_params.get('file')),
...(posargs ? {
args: {
command_line: { args: posargs },
+35 -15
View File
@@ -18,10 +18,25 @@
*/
import UIPermissionDialog from '../UI/UIPermissionDialog.js';
import { isUuid } from './sharePaths.js';
/**
* The launch options for a `?file=` URL value: a uid (what `getShareLink()`
* puts in a link) or a path, either way behind the consent gate below.
*
* @param {string | null | undefined} value
* @returns {{ file_uid?: string, file_path?: string, confirm_file_access?: true }}
*/
export const urlFileLaunchOptions = (value) => {
if ( ! value ) return {};
return isUuid(value)
? { file_uid: value, confirm_file_access: true }
: { file_path: value, confirm_file_access: true };
};
/**
* Consent gate for a launch whose file was named by the URL rather than by the
* user. Both the app and the path come from whoever wrote the link, so signing
* user. Both the app and the file come from whoever wrote the link, so signing
* one over silently would let a single navigation hand a stranger's app a
* standing grant on a file the user never picked.
*
@@ -30,45 +45,50 @@ import UIPermissionDialog from '../UI/UIPermissionDialog.js';
* whole tree beneath it.
*
* @param {object} request
* @param {string} request.path - Absolute path, already home-expanded.
* @param {string} [request.path] - Absolute path, already home-expanded.
* @param {string} [request.uid] - The file's uid; used instead of `path`.
* @param {string} request.appUid - The app the grant would be written against.
* @param {string} [request.appName] - Registered name, for display only.
* @param {object} [deps] Injectable seams for tests.
* @param {(path: string) => Promise<{ is_dir?: boolean }>} [deps.stat]
* @param {(target: { path?: string, uid?: string }) => Promise<{ uid?: string, path?: string, is_dir?: boolean }>} [deps.stat]
* @param {(options: object) => Promise<boolean>} [deps.permissionDialog]
* @returns {Promise<boolean>} `true` only if the user allowed it.
* @returns {Promise<{ uid: string, path?: string } | null>} The file as
* stat'd, only if the user allowed it; `null` otherwise.
*/
export const confirmUrlFileAccess = async (
{ path, appUid, appName },
{ path, uid, appUid, appName },
{
stat = (p) => puter.fs.stat({ path: p, consistency: 'eventual' }),
stat = (target) => puter.fs.stat({ ...target, consistency: 'eventual' }),
permissionDialog = UIPermissionDialog,
} = {},
) => {
if ( ! path || ! appUid ) return false;
if ( (! path && ! uid) || ! appUid ) return null;
let fsentry;
try {
fsentry = await stat(path);
fsentry = await stat(uid ? { uid } : { path });
} catch (e) {
// A path that can't be resolved can't be signed either, so there is
// A file that can't be resolved can't be signed either, so there is
// nothing to consent to.
return false;
return null;
}
if ( fsentry?.is_dir ) {
console.warn(`launch_app: refusing to open the folder ${path}`);
return false;
if ( ! fsentry?.uid ) return null;
if ( fsentry.is_dir ) {
console.warn(`launch_app: refusing to open the folder ${fsentry.path ?? uid}`);
return null;
}
const granted = await permissionDialog({
app_uid: appUid,
app_name: appName,
permission: `fs:${path}:write`,
// By uid: it is what the grant is stored against, and a path a
// recipient sees is a masked stand-in for the owner's.
permission: `fs:${fsentry.uid}:write`,
// The entry was just stat'd; nothing here should bring one into being.
create: false,
});
return granted === true;
return granted === true ? { uid: fsentry.uid, path: fsentry.path } : null;
};
export default confirmUrlFileAccess;
@@ -24,10 +24,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
globalThis.window = globalThis.window ?? {};
globalThis.i18n = globalThis.i18n ?? ((key) => key);
const { confirmUrlFileAccess } = await import('./confirmUrlFileAccess.js');
const { confirmUrlFileAccess, urlFileLaunchOptions } = await import('./confirmUrlFileAccess.js');
const APP = 'app-uid-1';
const FILE = '/alice/Documents/notes.txt';
const FILE_UID = '2b7d8c1e-4f3a-4b6c-9d1e-0a1b2c3d4e5f';
let permissionDialog;
const deps = (stat) => ({ stat, permissionDialog });
@@ -37,50 +38,79 @@ beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
describe('urlFileLaunchOptions', () => {
it('sends a uid and a path down different launch options, both gated', () => {
expect(urlFileLaunchOptions(FILE_UID)).toEqual({ file_uid: FILE_UID, confirm_file_access: true });
expect(urlFileLaunchOptions(FILE)).toEqual({ file_path: FILE, confirm_file_access: true });
expect(urlFileLaunchOptions('~/notes.txt')).toEqual({ file_path: '~/notes.txt', confirm_file_access: true });
});
it('is empty without a value', () => {
expect(urlFileLaunchOptions(null)).toEqual({});
expect(urlFileLaunchOptions('')).toEqual({});
});
});
describe('confirmUrlFileAccess', () => {
it('asks for write on the named file and reports the user allowing it', async () => {
const stat = vi.fn(async () => ({ is_dir: false }));
it('asks for write on the named file and hands it back when the user allows', async () => {
const stat = vi.fn(async () => ({ uid: FILE_UID, path: FILE, is_dir: false }));
await expect(confirmUrlFileAccess(
{ path: FILE, appUid: APP, appName: 'notepad' }, deps(stat),
)).resolves.toBe(true);
)).resolves.toEqual({ uid: FILE_UID, path: FILE });
expect(stat).toHaveBeenCalledWith({ path: FILE });
expect(permissionDialog).toHaveBeenCalledWith({
app_uid: APP,
app_name: 'notepad',
permission: `fs:${FILE}:write`,
permission: `fs:${FILE_UID}:write`,
create: false,
});
});
it('resolves a uid the same way, with the path the viewer may use', async () => {
const masked = `/alice/${FILE_UID}/notes.txt`;
const stat = vi.fn(async () => ({ uid: FILE_UID, path: masked, is_dir: false }));
await expect(confirmUrlFileAccess(
{ uid: FILE_UID, appUid: APP }, deps(stat),
)).resolves.toEqual({ uid: FILE_UID, path: masked });
expect(stat).toHaveBeenCalledWith({ uid: FILE_UID });
expect(permissionDialog).toHaveBeenCalledWith(expect.objectContaining({
permission: `fs:${FILE_UID}:write`,
}));
});
it('reports a refusal when the user denies', async () => {
permissionDialog = vi.fn(async () => false);
const stat = vi.fn(async () => ({ is_dir: false }));
const stat = vi.fn(async () => ({ uid: FILE_UID, path: FILE, is_dir: false }));
await expect(confirmUrlFileAccess(
{ path: FILE, appUid: APP }, deps(stat),
)).resolves.toBe(false);
)).resolves.toBeNull();
});
it('refuses a directory without prompting — the grant would cover the tree', async () => {
const stat = vi.fn(async () => ({ is_dir: true }));
const stat = vi.fn(async () => ({ uid: 'dir-uid', path: '/alice', is_dir: true }));
await expect(confirmUrlFileAccess(
{ path: '/alice', appUid: APP }, deps(stat),
)).resolves.toBe(false);
)).resolves.toBeNull();
expect(permissionDialog).not.toHaveBeenCalled();
});
it('refuses a path it cannot stat', async () => {
it('refuses a file it cannot stat', async () => {
const stat = vi.fn(async () => { throw new Error('404'); });
await expect(confirmUrlFileAccess(
{ path: FILE, appUid: APP }, deps(stat),
)).resolves.toBe(false);
)).resolves.toBeNull();
await expect(confirmUrlFileAccess(
{ uid: FILE_UID, appUid: APP }, deps(stat),
)).resolves.toBeNull();
expect(permissionDialog).not.toHaveBeenCalled();
});
it('refuses without a path or an app to name the grant against', async () => {
const stat = vi.fn(async () => ({ is_dir: false }));
it('refuses without a file or an app to name the grant against', async () => {
const stat = vi.fn(async () => ({ uid: FILE_UID, path: FILE, is_dir: false }));
await expect(confirmUrlFileAccess({ path: FILE }, deps(stat)))
.resolves.toBe(false);
.resolves.toBeNull();
await expect(confirmUrlFileAccess({ appUid: APP }, deps(stat)))
.resolves.toBe(false);
.resolves.toBeNull();
expect(stat).not.toHaveBeenCalled();
expect(permissionDialog).not.toHaveBeenCalled();
});
+26 -16
View File
@@ -318,29 +318,37 @@ const launch_app = async (options) => {
{
file_signature = options.file_signature;
}
else if ( options.file_uid ) {
else if ( options.file_uid && ! options.confirm_file_access ) {
file_signature = await puter.fs.sign(app_info.uuid, { uid: options.file_uid, action: 'write' });
// add token to options
options.token = file_signature.token;
// add file_signature to options
file_signature = file_signature.items;
}
// A launch that names its file by path alone (a URL landing, a default-app
// preference). Sign it here so the app receives it as an opened item.
else if ( options.file_path ) {
options.file_path = expand_home_path(options.file_path, window.home_path);
// `confirm_file_access` marks a path the user didn't pick — see
// A launch that names its file by path or uid alone (a URL landing, a
// default-app preference). Sign it here so the app receives it as an
// opened item.
else if ( options.file_path || options.file_uid ) {
if ( options.file_path ) {
options.file_path = expand_home_path(options.file_path, window.home_path);
}
let target = options.file_uid ? { uid: options.file_uid } : { path: options.file_path };
// `confirm_file_access` marks a file the user didn't pick — see
// confirmUrlFileAccess. Refused, the app still opens, just without it.
const allowed = ! options.confirm_file_access || await confirmUrlFileAccess({
path: options.file_path,
appUid: app_info.uuid,
appName: app_info.name,
});
if ( ! allowed ) {
options.file_path = undefined;
} else {
if ( options.confirm_file_access ) {
const entry = await confirmUrlFileAccess({
...target,
appUid: app_info.uuid,
appName: app_info.name,
});
target = entry ? { uid: entry.uid } : null;
// The stat'd path names the window; a refusal clears both.
options.file_path = entry?.path;
options.file_uid = entry?.uid;
}
if ( target ) {
try {
const signed = await puter.fs.sign(app_info.uuid, { path: options.file_path, action: 'write' });
const signed = await puter.fs.sign(app_info.uuid, { ...target, action: 'write' });
// A path the user can't reach comes back as an empty signature
// rather than an error, so there is nothing to check but the uid.
if ( signed?.items?.uid ) {
@@ -348,12 +356,14 @@ const launch_app = async (options) => {
file_signature = signed.items;
} else {
options.file_path = undefined;
options.file_uid = undefined;
}
} catch ( e ) {
// Open the app without the file rather than not at all. Clearing
// the path also keeps it out of the window title.
console.warn(`launch_app: could not open ${options.file_path}`, e);
console.warn(`launch_app: could not open ${options.file_path ?? options.file_uid}`, e);
options.file_path = undefined;
options.file_uid = undefined;
}
}
}
+3
View File
@@ -28,6 +28,9 @@
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** @param {unknown} value */
export const isUuid = (value) => typeof value === 'string' && UUID.test(value);
/**
* @typedef {{owner: string, uid: string, segments: string[]}} SharedPathParts
*/
+11
View File
@@ -19,6 +19,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
isUuid,
is_share_root,
parent_path_for,
parse_shared_path,
@@ -156,3 +157,13 @@ describe('share_link_for', () => {
});
});
});
describe('isUuid', () => {
it('accepts a uuid in either case and nothing else', () => {
expect(isUuid(UID)).toBe(true);
expect(isUuid(UID.toUpperCase())).toBe(true);
expect(isUuid(`/${UID}`)).toBe(false);
expect(isUuid('notes.txt')).toBe(false);
expect(isUuid(undefined)).toBe(false);
});
});
+18 -4
View File
@@ -58,6 +58,7 @@ import { holdsPermissions } from './helpers/holdsPermissions.js';
import item_icon from './helpers/itemIcon.js';
import { installAppIconFallback } from './helpers/appIcon.js';
import launch_app from './helpers/launchApp.js';
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
import { parse_url_paths } from './helpers/urlPaths.js';
import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js';
import update_mouse_position from './helpers/updateMousePosition.js';
@@ -211,10 +212,10 @@ const postAuthActions = async (action) => {
// malformed posargs: launch without them
}
}
// `?file=<path>` opens that file with the app, the same as
// `?file=<path or uid>` opens that file with the app, the same as
// double-clicking it would — but the link picked both, so the user
// is asked before the app is given the file.
const file_path = window.url_query_params.get('file');
const fileLaunch = urlFileLaunchOptions(window.url_query_params.get('file'));
// The server titles /app/<name> pages after the app, so the
// launch's lazy base-title capture would keep the app's name
// forever — preset the title to fall back to when the app's
@@ -271,7 +272,7 @@ const postAuthActions = async (action) => {
maximized: true,
params: app_query_params,
readURL: window.url_query_params.get('readURL'),
...(file_path ? { file_path, confirm_file_access: true } : {}),
...fileLaunch,
...(app_obj ? { app_obj } : {}),
...(posargs ? {
args: {
@@ -1486,10 +1487,12 @@ window.initgui = async function (options) {
!(window.attempt_temp_user_creation && window.first_visit_ever)
) {
// Ensure current user is in logged_in_users (e.g. after OIDC redirect we have token but no user in list)
let currentUserUuid = window.user?.uuid ?? null;
try {
const whoami_popup = await puter.os.user({
query: 'icon_size=64',
});
currentUserUuid = whoami_popup?.uuid ?? currentUserUuid;
await window.update_auth_data(
whoami_popup.token || window.auth_token,
whoami_popup,
@@ -1506,7 +1509,18 @@ window.initgui = async function (options) {
// than the `oidc_login` query parameter it used to be read from:
// as a bare parameter anyone could write it, and it suppresses the
// one prompt standing between a link and a token.
if (window.oidcPopupReturn?.oidc_login) {
//
// The proof is bound to the account that completed OIDC, so it only
// stands in for the picker when that account is the one signed in
// here — otherwise a proof from one login would skip another
// browser's picker and mint that user a token unasked.
const proofMatchesCurrentUser =
window.oidcPopupReturn?.oidc_login &&
window.oidcPopupReturn?.user_uuid != null &&
currentUserUuid != null &&
String(window.oidcPopupReturn.user_uuid) ===
String(currentUserUuid);
if (proofMatchesCurrentUser) {
picked_a_user_for_sdk_login = true;
await window.getUserAppToken(window.openerOrigin);
} else {
+5 -2
View File
@@ -45,8 +45,10 @@
* @param {string|null|undefined} proof - The `opener_state` query parameter.
* @param {string|null|undefined} msgId - The popup's current `msg_id`. A proof
* minted for a different one belongs to another flow.
* @returns {Promise<{opener_origin: string|null, oidc_login: boolean}|null>}
* `null` when there is no usable proof.
* @returns {Promise<{opener_origin: string|null, oidc_login: boolean, user_uuid: string|null}|null>}
* `null` when there is no usable proof. `user_uuid` is the account that
* completed OIDC; the caller must confirm it matches the current user before
* treating `oidc_login` as consent to skip the account picker.
*/
export const verifyOidcPopupReturn = async (proof, msgId) => {
if (!proof) return null;
@@ -87,5 +89,6 @@ export const verifyOidcPopupReturn = async (proof, msgId) => {
return {
opener_origin: attested.opener_origin,
oidc_login: attested.oidc_login === true,
user_uuid: attested.user_uuid ?? null,
};
};
+22 -2
View File
@@ -44,10 +44,26 @@ describe('redeeming a proof', () => {
oidc_login: true,
});
await expect(verifyOidcPopupReturn('signed.blob.here', '7')).resolves.toEqual(
{ opener_origin: OPENER, oidc_login: true },
{ opener_origin: OPENER, oidc_login: true, user_uuid: null },
);
});
it('passes through the account the proof is bound to', async () => {
globalThis.fetch = serverSays({
opener_origin: OPENER,
msg_id: '7',
oidc_login: true,
user_uuid: 'user-A',
});
await expect(
verifyOidcPopupReturn('signed.blob.here', '7'),
).resolves.toEqual({
opener_origin: OPENER,
oidc_login: true,
user_uuid: 'user-A',
});
});
it('sends the proof to the verify endpoint', async () => {
const fetchMock = serverSays({ opener_origin: OPENER, oidc_login: true });
globalThis.fetch = fetchMock;
@@ -68,7 +84,11 @@ describe('redeeming a proof', () => {
});
await expect(
verifyOidcPopupReturn('signed.blob.here', null),
).resolves.toEqual({ opener_origin: OPENER, oidc_login: false });
).resolves.toEqual({
opener_origin: OPENER,
oidc_login: false,
user_uuid: null,
});
});
});
+1
View File
@@ -138,6 +138,7 @@ export type {
DeleteOptions,
FSItemRead,
FSItemWithShares,
GetShareLinkOptions,
GetSharesOptions,
ListSharedByMeOptions,
ListSharedOptions,
@@ -14,6 +14,7 @@ import FSItem from '../FSItem.js';
import copy from './operations/copy.js';
import deleteFSEntry from './operations/deleteFSEntry.js';
import getReadURL from './operations/getReadUrl.js';
import getShareLink from './operations/getShareLink.js';
import getShares from './operations/getShares.js';
import listShared from './operations/listShared.js';
import listSharedByMe from './operations/listSharedByMe.js';
@@ -67,6 +68,7 @@ export class PuterJSFileSystemModule extends PuterModule {
listShared = listShared;
listSharedByMe = listSharedByMe;
getShares = getShares;
getShareLink = getShareLink;
FSItem = FSItem;
@@ -0,0 +1,83 @@
import { parseOperationArgs } from './scaffold.js';
import stat from './stat.js';
/** @typedef {import('../types.js').GetShareLinkOptions} GetShareLinkOptions */
const UUID = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
/**
* @typedef {{
* (options: GetShareLinkOptions): Promise<string>,
* (
* item: string,
* appName?: string,
* success?: (value: string) => void,
* error?: (reason: unknown) => void,
* ): Promise<string>,
* }} GetShareLinkOperation
*/
/**
* Builds the link that opens a file in an app on Puter:
* `<origin>/app/<appName>?file=<uid>`. `item` is a path (relative paths
* resolve against the app's root directory) or a uid; `appName` defaults to
* the calling app.
*
* The link grants nothing by itself. Whoever follows it must already be able
* to reach the file — as its owner, as someone it was shared with, or as
* anyone once the file is open to anyone with the link — and is asked before
* the app is handed the file.
*
* @this {import('../index.js').PuterJSFileSystemModule}
* @param {...unknown} args
* @returns {Promise<string>}
*/
const getShareLinkImpl = async function (...args) {
const options = parseOperationArgs(args, ['item', 'appName']);
/** @param {{ message: string, code: string }} reason */
const fail = (reason) => {
if ( typeof options.error === 'function' ) options.error(reason);
throw reason;
};
const item = typeof options.item === 'string' ? options.item : undefined;
const uid = options.uid !== undefined
? String(options.uid)
: (item !== undefined && UUID.test(item) ? item : undefined);
const path = uid === undefined ? (options.path ?? item) : undefined;
if ( uid === undefined && (typeof path !== 'string' || path === '') ) {
return fail({ message: 'getShareLink() needs a path or a uid.', code: 'field_missing' });
}
const appName = options.appName ?? this.puter.appName;
if ( typeof appName !== 'string' || appName === '' ) {
return fail({
message: 'getShareLink() needs the name of the app to open the file with; pass `appName`.',
code: 'app_name_required',
});
}
let entry;
try {
entry = await stat.call(this, uid !== undefined ? { uid } : { path });
} catch (e) {
if ( typeof options.error === 'function' ) options.error(e);
throw e;
}
if ( entry.is_dir ) {
return fail({
message: 'getShareLink() needs a file; a directory cannot be opened with an app.',
code: 'not_a_file',
});
}
const origin = String(this.puter.defaultGUIOrigin).replace(/\/+$/, '');
const link = `${origin}/app/${encodeURIComponent(appName)}?file=${encodeURIComponent(entry.uid)}`;
if ( typeof options.success === 'function' ) options.success(link);
return link;
};
const getShareLink = /** @type {GetShareLinkOperation} */ (getShareLinkImpl);
export default getShareLink;
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import copy from './copy.js';
import deleteFSEntry from './deleteFSEntry.js';
import getReadURL from './getReadUrl.js';
import getShareLink from './getShareLink.js';
import mkdir from './mkdir.js';
import move from './move.js';
import read from './read.js';
@@ -81,11 +82,13 @@ const makeFS = () => ({
APIOrigin: 'https://api.test',
authToken: 'test-token',
socket: { id: 'socket-1' },
// getShareLink reads the GUI origin and the calling app off the instance.
get puter () { return globalThis.puter; },
// write delegates to upload, which has its own tests.
upload: vi.fn(async () => ({ uid: 'written' })),
copy, delete: deleteFSEntry, getReadURL, mkdir, move, read, readdir,
readdirSubdomains, rename, revokeReadURL, share, sign, space, stat,
unshare, write,
copy, delete: deleteFSEntry, getReadURL, getShareLink, mkdir, move, read,
readdir, readdirSubdomains, rename, revokeReadURL, share, sign, space,
stat, unshare, write,
});
const makeCache = () => {
@@ -670,3 +673,62 @@ describe('authentication gate', () => {
expect(FakeXHR.requests).toHaveLength(1);
});
});
describe('getShareLink', () => {
const FILE_UID = '2b7d8c1e-4f3a-4b6c-9d1e-0a1b2c3d4e5f';
const statFile = () => ({ uid: FILE_UID, is_dir: false });
beforeEach(() => {
globalThis.puter.defaultGUIOrigin = 'https://gui.test/';
globalThis.puter.appName = undefined;
});
it('stats the path and builds the app link on the file uid', async () => {
FakeXHR.respondWith = statFile;
const link = await fs.getShareLink('/a/file.txt', 'editor');
expect(lastRequest().url).toBe('https://api.test/stat');
expect(lastBody()).toMatchObject({ path: '/a/file.txt' });
expect(link).toBe(`https://gui.test/app/editor?file=${FILE_UID}`);
});
it('takes a uid in place of a path, and the options form', async () => {
FakeXHR.respondWith = statFile;
await fs.getShareLink(FILE_UID, 'editor');
expect(lastBody()).toMatchObject({ uid: FILE_UID });
expect(lastBody().path).toBeUndefined();
const link = await fs.getShareLink({ uid: FILE_UID, appName: 'my app' });
expect(link).toBe(`https://gui.test/app/my%20app?file=${FILE_UID}`);
});
it('defaults the app to the one the SDK runs in', async () => {
FakeXHR.respondWith = statFile;
globalThis.puter.appName = 'notepad';
expect(await fs.getShareLink('/a/file.txt')).toBe(`https://gui.test/app/notepad?file=${FILE_UID}`);
});
it('rejects before the network without a file or an app', async () => {
await expect(fs.getShareLink('/a/file.txt')).rejects.toMatchObject({ code: 'app_name_required' });
await expect(fs.getShareLink({ appName: 'editor' })).rejects.toMatchObject({ code: 'field_missing' });
expect(FakeXHR.requests).toHaveLength(0);
});
it('rejects a directory', async () => {
FakeXHR.respondWith = () => ({ uid: 'dir-uid', is_dir: true });
await expect(fs.getShareLink('/a/dir', 'editor')).rejects.toMatchObject({ code: 'not_a_file' });
});
it('feeds legacy callbacks the same result', async () => {
FakeXHR.respondWith = statFile;
const success = vi.fn();
const error = vi.fn();
const link = await fs.getShareLink('/a/file.txt', 'editor', success, error);
expect(success).toHaveBeenCalledWith(link);
expect(error).not.toHaveBeenCalled();
const failed = vi.fn();
await expect(fs.getShareLink('/a/file.txt', undefined, vi.fn(), failed))
.rejects.toMatchObject({ code: 'app_name_required' });
expect(failed).toHaveBeenCalledWith(expect.objectContaining({ code: 'app_name_required' }));
});
});
@@ -430,4 +430,17 @@
* @typedef {GetSharesOptionsOwn & RequestCallbacks<Share[]>} GetSharesOptions
*/
/**
* @typedef {Object} GetShareLinkOptionsOwn
* @property {string} [path] The file. Required when passing options as the only argument, unless
* `uid` is given.
* @property {string} [uid] The file, by UID. Can be used instead of `path`.
* @property {string} [appName] Name of the app the link opens the file with. Defaults to the app
* the code runs in.
*/
/**
* @typedef {GetShareLinkOptionsOwn & RequestCallbacks<string>} GetShareLinkOptions
*/
export {};
@@ -82,6 +82,37 @@ export default suite('sharing', {
t.assert.ok(closed.status !== 200, `should close again (got ${closed.status})`);
},
'getShareLink builds the link that opens a file in an app': async (t) => {
const path = scratch(t, 'link');
const written = await t.puter.fs.write(path, 'open me');
const guiOrigin = new URL(t.puter.defaultGUIOrigin).origin;
const link = new URL(await t.puter.fs.getShareLink(path, 'editor'));
t.assert.equal(link.origin, guiOrigin);
t.assert.equal(link.pathname, '/app/editor');
t.assert.equal(link.searchParams.get('file'), written.uid);
// A uid stands in for the path, and so does the options form.
t.assert.equal(await t.puter.fs.getShareLink(written.uid, 'editor'), link.href);
t.assert.equal(
await t.puter.fs.getShareLink({ uid: written.uid, appName: 'editor' }),
link.href,
);
},
'getShareLink refuses a directory and needs an app to open with': async (t) => {
const dir = `${home(t)}/sharing-linkdir-${Math.random().toString(36).slice(2, 8)}`;
await t.puter.fs.mkdir(dir);
const notAFile = await t.assert.rejects(() => t.puter.fs.getShareLink(dir, 'editor'));
t.assert.equal((notAFile as { code?: string }).code, 'not_a_file');
const path = scratch(t, 'noapp');
await t.puter.fs.write(path, 'x');
// Outside a Puter app the SDK has no app of its own to fall back on.
const noApp = await t.assert.rejects(() => t.puter.fs.getShareLink(path));
t.assert.equal((noApp as { code?: string }).code, 'app_name_required');
},
'share accepts an options object and defaults to read': async (t) => {
const path = scratch(t, 'options');
await t.puter.fs.write(path, 'x');