mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-29 08:38:06 +00:00
fix: misc hardening + other fixes (#3906)
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Vendored
+1
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user