mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
feat(share): add ShareService with a per-day share limit
A share is two writes that belong together: the permission grant, which authorizes access, and a share row, which makes it listable and ties it to an fsentry so it dies with the file. Nothing else grants fs:* to a user. Authorization reuses canManagePermission — an owner satisfies it through the is-owner implicator, a delegate through an explicit manage:fs:<uid> grant. An owner may clear any issuer's share of their node; anyone else only the ones they issued, or their own access. Self-revoke skips the manage gate but still requires `see`, so it cannot be used to probe for files. The per-day limit counts shares created rather than live rows, so revoking and re-sharing cannot recycle a slot, and changing an existing share's mode is not new reach and does not spend budget. Tunable via share_daily_limit.
This commit is contained in:
@@ -36,6 +36,7 @@ import { MeteringService } from './metering/MeteringService';
|
||||
import { NotificationService } from './notification/NotificationService';
|
||||
import { PermissionService } from './permission/PermissionService';
|
||||
import { DefaultUserService } from './selfhosted/DefaultUserService';
|
||||
import { ShareService } from './share/ShareService';
|
||||
import { SocketService } from './socket/SocketService';
|
||||
import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService';
|
||||
import type { IPuterServiceRegistry } from './types';
|
||||
@@ -53,6 +54,7 @@ declare module './types' {
|
||||
appOriginBlocklist: AppOriginBlocklistService;
|
||||
permission: PermissionService;
|
||||
acl: ACLService;
|
||||
share: ShareService;
|
||||
token: TokenService;
|
||||
auth: AuthService;
|
||||
fs: FSService;
|
||||
@@ -91,6 +93,9 @@ export const puterServices = {
|
||||
token: TokenService,
|
||||
auth: AuthService,
|
||||
fs: FSService,
|
||||
// Needs acl (setUserUser), permission (canManagePermission) and fs
|
||||
// (ancestor chains), so it follows all three.
|
||||
share: ShareService,
|
||||
// AppPermissionService + SubdomainPermissionService register permission
|
||||
// rewriters/implicators only; no runtime state. Placed after fsEntry so
|
||||
// the FS rewriter runs first for `fs:/path` → `fs:<uuid>` before any
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/*
|
||||
* 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 { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
|
||||
describe('ShareService', () => {
|
||||
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 });
|
||||
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 (?, ?, ?, ?, 0, ?)',
|
||||
[uuid, name, path, owner.id, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(path);
|
||||
if (!entry) throw new Error('fsentry not created');
|
||||
return entry;
|
||||
};
|
||||
|
||||
const canRead = async (actor: Actor, path: string) =>
|
||||
server.services.acl.check(
|
||||
actor,
|
||||
{
|
||||
path,
|
||||
resolveAncestors: () => server.services.fs.getAncestorChain(path),
|
||||
},
|
||||
'read',
|
||||
);
|
||||
|
||||
const share = (actor: Actor, input: Record<string, unknown>) =>
|
||||
runWithContext({ actor }, () =>
|
||||
server.services.share.share(actor, input as never),
|
||||
);
|
||||
|
||||
const unshare = (actor: Actor, input: Record<string, unknown>) =>
|
||||
runWithContext({ actor }, () =>
|
||||
server.services.share.unshare(actor, input as never),
|
||||
);
|
||||
|
||||
it('grants access and indexes the share', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(false);
|
||||
|
||||
const result = await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('read');
|
||||
expect(result.path).toBe(file.path);
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(true);
|
||||
|
||||
const listed = await server.services.share.listSharedWithMe(
|
||||
recipient.actor,
|
||||
);
|
||||
expect(listed.items.map((i) => i.entryUid)).toContain(file.uuid);
|
||||
});
|
||||
|
||||
it('resolves a recipient by username as well as email', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { username: recipient.user.username },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to share with yourself or with the owner', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await expect(
|
||||
share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: owner.email },
|
||||
mode: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('refuses an unknown mode and an unknown recipient', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await expect(
|
||||
share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: 'nobody@nowhere.test' },
|
||||
mode: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
|
||||
await expect(
|
||||
share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: 'nobody@nowhere.test' },
|
||||
mode: 'wizard',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('hides a file from a stranger trying to share it', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
// 404 rather than 403 — a failed share must not confirm the file
|
||||
// exists to someone who cannot even see it.
|
||||
await expect(
|
||||
share(stranger.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('revokes access and drops the index row', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(true);
|
||||
|
||||
const result = await unshare(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
});
|
||||
|
||||
expect(result.revoked).toBe(1);
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(false);
|
||||
const listed = await server.services.share.listSharedWithMe(
|
||||
recipient.actor,
|
||||
);
|
||||
expect(listed.items.map((i) => i.entryUid)).not.toContain(file.uuid);
|
||||
});
|
||||
|
||||
it('refuses to revoke the owner', async () => {
|
||||
const owner = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await expect(
|
||||
unshare(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: owner.email },
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('shows the owner a share a manage delegate issued', async () => {
|
||||
const owner = await makeUser();
|
||||
const delegate = await makeUser();
|
||||
const third = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: delegate.email },
|
||||
mode: 'manage',
|
||||
});
|
||||
await share(delegate.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: third.email },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
// The owner cannot see this through the permission tables, which are
|
||||
// keyed issuer→holder; the index is what answers it.
|
||||
const rows = await server.services.share.listSharesOf(owner.actor, {
|
||||
uid: file.uuid,
|
||||
});
|
||||
const holders = rows.map((r) => r.holder.username);
|
||||
expect(holders).toContain(delegate.user.username);
|
||||
expect(holders).toContain(third.user.username);
|
||||
expect(
|
||||
rows.find((r) => r.holder.username === third.user.username)?.issuer
|
||||
.username,
|
||||
).toBe(delegate.user.username);
|
||||
});
|
||||
|
||||
it('lets a delegate clear only what it issued', async () => {
|
||||
const owner = await makeUser();
|
||||
const delegate = await makeUser();
|
||||
const third = await makeUser();
|
||||
const fourth = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: delegate.email },
|
||||
mode: 'manage',
|
||||
});
|
||||
await share(delegate.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: third.email },
|
||||
mode: 'read',
|
||||
});
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: fourth.email },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
// Its own grant: cleared.
|
||||
expect(
|
||||
(
|
||||
await unshare(delegate.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: third.email },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(1);
|
||||
expect(await canRead(third.actor, file.path)).toBe(false);
|
||||
|
||||
// The owner's grant to someone else: untouched.
|
||||
expect(
|
||||
(
|
||||
await unshare(delegate.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: fourth.email },
|
||||
})
|
||||
).revoked,
|
||||
).toBe(0);
|
||||
expect(await canRead(fourth.actor, file.path)).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the owner clear a grant a delegate issued', async () => {
|
||||
const owner = await makeUser();
|
||||
const delegate = await makeUser();
|
||||
const third = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: delegate.email },
|
||||
mode: 'manage',
|
||||
});
|
||||
await share(delegate.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: third.email },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
const byOwner = await unshare(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: third.email },
|
||||
});
|
||||
expect(byOwner.revoked).toBe(1);
|
||||
expect(await canRead(third.actor, file.path)).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a recipient leave a share they did not issue', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
// Dropping your own access is always allowed, whoever granted it.
|
||||
const left = await unshare(recipient.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
});
|
||||
expect(left.revoked).toBe(1);
|
||||
expect(await canRead(recipient.actor, file.path)).toBe(false);
|
||||
});
|
||||
|
||||
it('will not let leaving a share reveal a file you cannot see', async () => {
|
||||
const owner = await makeUser();
|
||||
const stranger = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
// Self-revoke skips the manage gate, so it still has to 404 here or it
|
||||
// becomes an existence oracle for any uid a stranger cares to guess.
|
||||
await expect(
|
||||
unshare(stranger.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: stranger.email },
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
describe('daily quota', () => {
|
||||
const withLimit = async (limit: number, fn: () => Promise<void>) => {
|
||||
const cfg = (
|
||||
server.services.share as unknown as {
|
||||
config: { share_daily_limit?: number };
|
||||
}
|
||||
).config;
|
||||
const previous = cfg.share_daily_limit;
|
||||
cfg.share_daily_limit = limit;
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
cfg.share_daily_limit = previous;
|
||||
}
|
||||
};
|
||||
|
||||
it('refuses a new share once the day budget is spent', async () => {
|
||||
const owner = await makeUser();
|
||||
const first = await makeUser();
|
||||
const second = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await withLimit(1, async () => {
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: first.email },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
await expect(
|
||||
share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: second.email },
|
||||
mode: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
it('does not spend budget on changing an existing share mode', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await withLimit(1, async () => {
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
// Same pair, new mode — reach is unchanged, so it must not
|
||||
// count against the budget the first share already spent.
|
||||
const upgraded = await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'write',
|
||||
});
|
||||
expect(upgraded.mode).toBe('write');
|
||||
});
|
||||
});
|
||||
|
||||
it('counts creations, so revoking does not refund the slot', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const other = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await withLimit(1, async () => {
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
await unshare(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
});
|
||||
|
||||
await expect(
|
||||
share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: other.email },
|
||||
mode: 'read',
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 429 });
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a non-positive limit as unlimited', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await withLimit(0, async () => {
|
||||
const result = await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
expect(result.mode).toBe('read');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('moves an existing share to a new mode rather than stacking one', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'write',
|
||||
});
|
||||
|
||||
const rows = await server.services.share.listSharesOf(owner.actor, {
|
||||
uid: file.uuid,
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].mode).toBe('write');
|
||||
});
|
||||
|
||||
it('retires grants when the entry is deleted', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const file = await makeFile(owner.user);
|
||||
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
|
||||
await server.services.share.onEntryDeleted(file.uuid);
|
||||
await server.clients.db.write(
|
||||
'DELETE FROM `fsentries` WHERE `uuid` = ?',
|
||||
[file.uuid],
|
||||
);
|
||||
|
||||
const listed = await server.services.share.listSharedWithMe(
|
||||
recipient.actor,
|
||||
);
|
||||
expect(listed.items.map((i) => i.entryUid)).not.toContain(file.uuid);
|
||||
const rows = await server.stores.permission.readLinkedUserUserPerms(
|
||||
recipient.user.id,
|
||||
[`fs:${file.uuid}:read`],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('paginates what has been shared with me', async () => {
|
||||
const owner = await makeUser();
|
||||
const recipient = await makeUser();
|
||||
const uids: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const file = await makeFile(owner.user);
|
||||
uids.push(file.uuid);
|
||||
await share(owner.actor, {
|
||||
uid: file.uuid,
|
||||
recipient: { email: recipient.email },
|
||||
mode: 'read',
|
||||
});
|
||||
}
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let guard = 0; guard < 6; guard++) {
|
||||
const page = await server.services.share.listSharedWithMe(
|
||||
recipient.actor,
|
||||
{ limit: 2, cursor },
|
||||
);
|
||||
seen.push(...page.items.map((i) => i.entryUid));
|
||||
cursor = page.cursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
expect(seen).toEqual(uids);
|
||||
|
||||
const withTotal = await server.services.share.listSharedWithMe(
|
||||
recipient.actor,
|
||||
{ includeTotal: true },
|
||||
);
|
||||
expect(withTotal.total).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,555 @@
|
||||
/*
|
||||
* 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 type { Actor } from '../../core/actor';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry';
|
||||
import type { LayerInstances } from '../../types';
|
||||
import type { AclMode } from '../acl/ACLService';
|
||||
import type { puterServices } from '../index';
|
||||
import { PuterService } from '../types';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
|
||||
/** A recipient named by whichever identifier the caller had. */
|
||||
export interface ShareRecipient {
|
||||
email?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ShareTarget {
|
||||
path?: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface ShareInput extends ShareTarget {
|
||||
recipient: ShareRecipient;
|
||||
mode: AclMode;
|
||||
}
|
||||
|
||||
/** One live share, resolved for a response. */
|
||||
export interface ResolvedShare {
|
||||
uid: string;
|
||||
mode: string;
|
||||
path: string;
|
||||
entryUid: string;
|
||||
isDir: boolean;
|
||||
issuer: { username: string | null };
|
||||
holder: { username: string | null };
|
||||
createdAt: unknown;
|
||||
}
|
||||
|
||||
const SHAREABLE_MODES: ReadonlySet<string> = new Set([
|
||||
'see',
|
||||
'list',
|
||||
'read',
|
||||
'write',
|
||||
'manage',
|
||||
]);
|
||||
|
||||
/** Shares one user may create per UTC day, absent a config override. */
|
||||
export const DEFAULT_DAILY_SHARE_LIMIT = 200;
|
||||
|
||||
// -- ShareService -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sharing a filesystem node with another user.
|
||||
*
|
||||
* A share is two writes that belong together: the permission grant, which is
|
||||
* what actually authorizes access, and a `share` row, which is what makes the
|
||||
* share listable and ties it to an fsentry so it dies with the file. This
|
||||
* service owns that pairing — nothing else should grant `fs:*` to a user.
|
||||
*
|
||||
* Authorization reuses `PermissionService.canManagePermission`: an owner
|
||||
* satisfies it through the `is-owner` implicator, a delegate through an
|
||||
* explicit `manage:fs:<uid>` grant.
|
||||
*/
|
||||
export class ShareService extends PuterService {
|
||||
declare protected services: LayerInstances<typeof puterServices>;
|
||||
|
||||
// -- Writes -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Grant `mode` on a node to a recipient and index it.
|
||||
*
|
||||
* The permission is written first: if the index write then fails because
|
||||
* the entry died mid-flight, the grant is rolled back rather than left
|
||||
* standing invisibly.
|
||||
*/
|
||||
async share(actor: Actor, input: ShareInput): Promise<ResolvedShare> {
|
||||
const issuerId = this.#requireUserId(actor);
|
||||
const mode = this.#requireMode(input.mode);
|
||||
|
||||
// Independent reads; the authorization check needs only the entry.
|
||||
const [entry, holder] = await Promise.all([
|
||||
this.#resolveEntry(input),
|
||||
this.#resolveRecipient(input.recipient),
|
||||
]);
|
||||
|
||||
await this.#assertCanManage(actor, entry);
|
||||
|
||||
if (holder.id === issuerId) {
|
||||
throw new HttpError(400, 'cannot share with yourself', {
|
||||
legacyCode: 'cannot_share_with_self',
|
||||
});
|
||||
}
|
||||
if (holder.id === entry.userId) {
|
||||
throw new HttpError(400, 'recipient already owns this item', {
|
||||
legacyCode: 'cannot_share_with_owner',
|
||||
});
|
||||
}
|
||||
|
||||
// Changing the mode on an existing share isn't new reach, so it
|
||||
// shouldn't spend budget — only a share to someone who doesn't already
|
||||
// have one on this node counts.
|
||||
const existing = await this.stores.share.listByFsentry(entry.id);
|
||||
const isNewShare = !existing.some(
|
||||
(row: { holder_user_id: number; issuer_user_id: number }) =>
|
||||
row.holder_user_id === holder.id &&
|
||||
row.issuer_user_id === issuerId,
|
||||
);
|
||||
if (isNewShare) await this.#assertDailyQuota(issuerId);
|
||||
|
||||
const holderActor: Actor = {
|
||||
user: {
|
||||
id: holder.id,
|
||||
uuid: holder.uuid,
|
||||
username: holder.username,
|
||||
} as Actor['user'],
|
||||
effectiveApp: null,
|
||||
};
|
||||
|
||||
await this.services.acl.setUserUser(
|
||||
actor,
|
||||
holderActor,
|
||||
this.#descriptorFor(entry),
|
||||
mode,
|
||||
);
|
||||
|
||||
try {
|
||||
const row = await this.stores.share.upsertActive({
|
||||
issuerUserId: issuerId,
|
||||
holderUserId: holder.id,
|
||||
fsentryId: entry.id,
|
||||
mode,
|
||||
recipientEmail: holder.email ?? null,
|
||||
});
|
||||
if (isNewShare) {
|
||||
await this.stores.share
|
||||
.incrementDailyShareCount(issuerId)
|
||||
.catch(() => {
|
||||
// A share that already landed must not fail over its
|
||||
// own bookkeeping.
|
||||
});
|
||||
}
|
||||
return this.#resolve(row, entry, actor, holder);
|
||||
} catch (err) {
|
||||
// The entry went away between the grant and the index write, so
|
||||
// the grant now points at nothing. Undo it rather than leave an
|
||||
// invisible permission behind.
|
||||
await this.#revokeQuietly(actor, entry, holder.username, issuerId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async unshare(
|
||||
actor: Actor,
|
||||
input: ShareTarget & { recipient: ShareRecipient },
|
||||
): Promise<{ revoked: number }> {
|
||||
const issuerId = this.#requireUserId(actor);
|
||||
const [entry, holder] = await Promise.all([
|
||||
this.#resolveEntry(input),
|
||||
this.#resolveRecipient(input.recipient),
|
||||
]);
|
||||
|
||||
// Dropping your own access needs no authority over the node — only
|
||||
// enough visibility that the call can't be used to probe for one.
|
||||
const isLeaving = holder.id === issuerId;
|
||||
if (isLeaving) {
|
||||
await this.#assertCanSee(actor, entry);
|
||||
} else {
|
||||
await this.#assertCanManage(actor, entry);
|
||||
}
|
||||
|
||||
if (holder.id === entry.userId) {
|
||||
throw new HttpError(400, 'cannot revoke the owner of an item', {
|
||||
legacyCode: 'cannot_revoke_owner',
|
||||
});
|
||||
}
|
||||
|
||||
// An owner may clear any issuer's share of their node; anyone else may
|
||||
// clear the ones they issued, or their own access.
|
||||
const isOwner = entry.userId === issuerId;
|
||||
const rows = (await this.stores.share.listByFsentry(entry.id)).filter(
|
||||
(row: { holder_user_id: number; issuer_user_id: number }) =>
|
||||
row.holder_user_id === holder.id &&
|
||||
(isOwner || isLeaving || row.issuer_user_id === issuerId),
|
||||
);
|
||||
|
||||
// Fall back to the issuer's own grant when no index row exists — the
|
||||
// grant may predate the index, and a revoke must still work.
|
||||
const issuers =
|
||||
rows.length > 0
|
||||
? [
|
||||
...new Set(
|
||||
rows.map(
|
||||
(row: { issuer_user_id: number }) =>
|
||||
row.issuer_user_id,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [issuerId];
|
||||
|
||||
let revoked = 0;
|
||||
for (const issuer of issuers) {
|
||||
const didRevoke = await this.#revokeFor(
|
||||
actor,
|
||||
entry,
|
||||
holder.username,
|
||||
issuer as number,
|
||||
);
|
||||
if (didRevoke) revoked++;
|
||||
await this.stores.share.deleteActive({
|
||||
holderUserId: holder.id,
|
||||
fsentryId: entry.id,
|
||||
issuerUserId: issuer as number,
|
||||
});
|
||||
}
|
||||
return { revoked };
|
||||
}
|
||||
|
||||
/** Retire the grants pointing at a node that no longer exists. */
|
||||
async onEntryDeleted(entryUid: string): Promise<void> {
|
||||
await this.stores.permission.deleteUserUserPermsByPermissionPrefix(
|
||||
`fs:${entryUid}`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- 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.
|
||||
*/
|
||||
async listSharedWithMe(
|
||||
actor: Actor,
|
||||
opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {},
|
||||
): Promise<{
|
||||
items: ResolvedShare[];
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
}> {
|
||||
const holderId = this.#requireUserId(actor);
|
||||
const page = await this.stores.share.listByHolder(holderId, {
|
||||
limit: opts.limit,
|
||||
cursor: opts.cursor,
|
||||
});
|
||||
|
||||
const entries = await this.stores.fsEntry.getEntriesByIds(
|
||||
page.items.map((row: { fsentry_id: number }) => row.fsentry_id),
|
||||
);
|
||||
const issuers = await this.stores.user.getByIds(
|
||||
page.items.map((row: { issuer_user_id: number }) =>
|
||||
Number(row.issuer_user_id),
|
||||
),
|
||||
);
|
||||
|
||||
const items: ResolvedShare[] = [];
|
||||
for (const row of page.items) {
|
||||
const entry = entries.get(Number(row.fsentry_id));
|
||||
if (!entry || this.#isTrashed(entry)) continue;
|
||||
const issuer = issuers.get(Number(row.issuer_user_id));
|
||||
items.push({
|
||||
uid: row.uid,
|
||||
mode: row.mode,
|
||||
path: entry.path,
|
||||
entryUid: entry.uuid,
|
||||
isDir: Boolean(entry.isDir),
|
||||
issuer: { username: issuer?.username ?? null },
|
||||
holder: { username: actor.user.username ?? null },
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
...(page.cursor ? { cursor: page.cursor } : {}),
|
||||
...(opts.includeTotal
|
||||
? { total: await this.stores.share.countByHolder(holderId) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Who can reach one node. Includes shares a `manage` delegate issued, which
|
||||
* the permission tables alone can't show the owner.
|
||||
*/
|
||||
async listSharesOf(
|
||||
actor: Actor,
|
||||
target: ShareTarget,
|
||||
): Promise<ResolvedShare[]> {
|
||||
const entry = await this.#resolveEntry(target);
|
||||
await this.#assertCanManage(actor, entry);
|
||||
|
||||
const rows = await this.stores.share.listByFsentry(entry.id);
|
||||
const userIds = rows.flatMap(
|
||||
(row: { issuer_user_id: number; holder_user_id: number }) => [
|
||||
Number(row.issuer_user_id),
|
||||
Number(row.holder_user_id),
|
||||
],
|
||||
);
|
||||
const users = await this.stores.user.getByIds(userIds);
|
||||
|
||||
return rows.map(
|
||||
(row: {
|
||||
uid: string;
|
||||
mode: string;
|
||||
issuer_user_id: number;
|
||||
holder_user_id: number;
|
||||
created_at: unknown;
|
||||
}) => ({
|
||||
uid: row.uid,
|
||||
mode: row.mode,
|
||||
path: entry.path,
|
||||
entryUid: entry.uuid,
|
||||
isDir: Boolean(entry.isDir),
|
||||
issuer: {
|
||||
username:
|
||||
users.get(Number(row.issuer_user_id))?.username ?? null,
|
||||
},
|
||||
holder: {
|
||||
username:
|
||||
users.get(Number(row.holder_user_id))?.username ?? null,
|
||||
},
|
||||
createdAt: row.created_at,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
#resolve(
|
||||
row: { uid: string; mode: string; created_at?: unknown },
|
||||
entry: FSEntry,
|
||||
issuer: Actor,
|
||||
holder: { username: string | null },
|
||||
): ResolvedShare {
|
||||
return {
|
||||
uid: row.uid,
|
||||
mode: row.mode,
|
||||
path: entry.path,
|
||||
entryUid: entry.uuid,
|
||||
isDir: Boolean(entry.isDir),
|
||||
issuer: { username: issuer.user.username ?? null },
|
||||
holder: { username: holder.username ?? null },
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
#requireUserId(actor: Actor): number {
|
||||
const id = actor?.user?.id;
|
||||
if (typeof id !== 'number') {
|
||||
throw new HttpError(403, 'actor must be a user', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
#requireMode(mode: string): AclMode {
|
||||
if (!SHAREABLE_MODES.has(mode)) {
|
||||
throw new HttpError(400, `unknown share mode: ${mode}`, {
|
||||
legacyCode: 'invalid_mode',
|
||||
});
|
||||
}
|
||||
return mode as AclMode;
|
||||
}
|
||||
|
||||
async #resolveEntry(target: ShareTarget): Promise<FSEntry> {
|
||||
const entry = target.uid
|
||||
? await this.stores.fsEntry.getEntryByUuid(target.uid)
|
||||
: target.path
|
||||
? await this.stores.fsEntry.getEntryByPath(target.path)
|
||||
: null;
|
||||
if (!entry) {
|
||||
throw new HttpError(404, 'Subject does not exist', {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
async #resolveRecipient(recipient: ShareRecipient) {
|
||||
const email = recipient?.email?.trim();
|
||||
const username = recipient?.username?.trim();
|
||||
const user = email
|
||||
? await this.stores.user.getByEmail(email)
|
||||
: username
|
||||
? await this.stores.user.getByUsername(username)
|
||||
: null;
|
||||
if (!user?.username) {
|
||||
throw new HttpError(404, 'Recipient does not exist', {
|
||||
legacyCode: 'user_does_not_exist',
|
||||
});
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate every share operation on the same question the permission layer
|
||||
* already answers. Reported as the ACL's own safe error so a caller who
|
||||
* can't even see the node learns nothing from the difference.
|
||||
*/
|
||||
async #assertCanManage(actor: Actor, entry: FSEntry): Promise<void> {
|
||||
const allowed = await this.services.permission.canManagePermission(
|
||||
actor,
|
||||
`fs:${entry.uuid}:read`,
|
||||
);
|
||||
if (allowed) return;
|
||||
|
||||
const safe = await this.services.acl.getSafeAclError(
|
||||
actor,
|
||||
this.#descriptorFor(entry),
|
||||
'manage',
|
||||
);
|
||||
throw new HttpError(safe.status, safe.message, {
|
||||
legacyCode: safe.fields.code,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted per creation rather than against live rows, so revoking and
|
||||
* re-sharing can't recycle the same slot. Checked before the write and
|
||||
* incremented after, so concurrent shares can overshoot by at most the
|
||||
* request's fan-out — fine for an abuse bound.
|
||||
*/
|
||||
async #assertDailyQuota(userId: number): Promise<void> {
|
||||
const limit =
|
||||
this.config.share_daily_limit ?? DEFAULT_DAILY_SHARE_LIMIT;
|
||||
if (limit <= 0) return;
|
||||
|
||||
const used = await this.stores.share.getDailyShareCount(userId);
|
||||
if (used < limit) return;
|
||||
|
||||
throw new HttpError(
|
||||
429,
|
||||
`daily share limit reached (${limit}); try again tomorrow`,
|
||||
{ legacyCode: 'share_daily_limit_reached' },
|
||||
);
|
||||
}
|
||||
|
||||
async #assertCanSee(actor: Actor, entry: FSEntry): Promise<void> {
|
||||
const descriptor = this.#descriptorFor(entry);
|
||||
if (await this.services.acl.check(actor, descriptor, 'see')) return;
|
||||
const safe = await this.services.acl.getSafeAclError(
|
||||
actor,
|
||||
descriptor,
|
||||
'see',
|
||||
);
|
||||
throw new HttpError(safe.status, safe.message, {
|
||||
legacyCode: safe.fields.code,
|
||||
});
|
||||
}
|
||||
|
||||
#descriptorFor(entry: FSEntry) {
|
||||
const fsService = this.services.fs;
|
||||
let cache: Promise<
|
||||
ReadonlyArray<{ uid: string; path: string }>
|
||||
> | null = null;
|
||||
return {
|
||||
path: entry.path,
|
||||
resolveAncestors: () => {
|
||||
if (!cache) cache = fsService.getAncestorChain(entry.path);
|
||||
return cache;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#isTrashed(entry: FSEntry): boolean {
|
||||
return /^\/[^/]+\/Trash(\/|$)/u.test(entry.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear whichever modes the recipient holds on this node.
|
||||
*
|
||||
* Skips any the actor can't manage rather than aborting: stripping a
|
||||
* `manage` grant needs `manage:manage:fs:<uid>`, which only the owner
|
||||
* holds, so a delegate withdrawing a plain `read` would otherwise fail on
|
||||
* reaching the manage form.
|
||||
*/
|
||||
async #revokeFor(
|
||||
actor: Actor,
|
||||
entry: FSEntry,
|
||||
username: string,
|
||||
issuerUserId: number,
|
||||
): Promise<boolean> {
|
||||
const permissions = [
|
||||
`fs:${entry.uuid}:see`,
|
||||
`fs:${entry.uuid}:list`,
|
||||
`fs:${entry.uuid}:read`,
|
||||
`fs:${entry.uuid}:write`,
|
||||
`manage:fs:${entry.uuid}`,
|
||||
];
|
||||
const isSelf = username === actor.user.username;
|
||||
const manageable = isSelf
|
||||
? permissions.map(() => true)
|
||||
: await Promise.all(
|
||||
permissions.map((permission) =>
|
||||
this.services.permission.canManagePermission(
|
||||
actor,
|
||||
permission,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
let revoked = false;
|
||||
for (let i = 0; i < permissions.length; i++) {
|
||||
if (!manageable[i]) continue;
|
||||
const didRevoke =
|
||||
await this.services.permission.revokeUserUserPermission(
|
||||
actor,
|
||||
username,
|
||||
permissions[i],
|
||||
{ reason: 'unshared' },
|
||||
{ issuerUserId },
|
||||
);
|
||||
if (didRevoke) revoked = true;
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
async #revokeQuietly(
|
||||
actor: Actor,
|
||||
entry: FSEntry,
|
||||
username: string,
|
||||
issuerUserId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.#revokeFor(actor, entry, username, issuerUserId);
|
||||
} catch {
|
||||
// Already failing the request; don't mask the original error.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,14 @@ export class ShareStore extends PuterStore {
|
||||
* (holder, fsentry, issuer) to match the table's unique index — two people
|
||||
* with manage rights each keep their own row rather than overwriting.
|
||||
*/
|
||||
/**
|
||||
* @param {object} input
|
||||
* @param {number} input.issuerUserId
|
||||
* @param {number} input.holderUserId
|
||||
* @param {number} input.fsentryId
|
||||
* @param {string} input.mode
|
||||
* @param {string | null} [input.recipientEmail]
|
||||
*/
|
||||
async upsertActive({
|
||||
issuerUserId,
|
||||
holderUserId,
|
||||
@@ -188,6 +196,12 @@ export class ShareStore extends PuterStore {
|
||||
* Drop one active share. Omit `issuerUserId` to clear every issuer's share
|
||||
* of that node with that holder — what an owner revoking access wants.
|
||||
*/
|
||||
/**
|
||||
* @param {object} input
|
||||
* @param {number} input.holderUserId
|
||||
* @param {number} input.fsentryId
|
||||
* @param {number | null} [input.issuerUserId]
|
||||
*/
|
||||
async deleteActive({ holderUserId, fsentryId, issuerUserId = null }) {
|
||||
const scoped = issuerUserId !== null && issuerUserId !== undefined;
|
||||
const result = await this.clients.db.write(
|
||||
@@ -204,6 +218,13 @@ export class ShareStore extends PuterStore {
|
||||
* Claim a pending invite for the user who signed up. Updates rather than
|
||||
* deletes, so the share survives as an index row.
|
||||
*/
|
||||
/**
|
||||
* @param {object} input
|
||||
* @param {string} input.uid
|
||||
* @param {number} input.holderUserId
|
||||
* @param {number | null} [input.fsentryId]
|
||||
* @param {string | null} [input.mode]
|
||||
*/
|
||||
async applyPending({ uid, holderUserId, fsentryId = null, mode = null }) {
|
||||
if (!uid || !holderUserId) {
|
||||
throw new Error('applyPending: uid and holderUserId are required');
|
||||
@@ -240,6 +261,44 @@ export class ShareStore extends PuterStore {
|
||||
return (result?.affectedRows ?? result?.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
// -- Daily quota --------------------------------------------------
|
||||
//
|
||||
// Counted in KV rather than by querying `share`, because the ceiling is on
|
||||
// shares *created* — rows the user later revoked still spent their budget,
|
||||
// so a COUNT of live rows would let a script recycle the same slot forever.
|
||||
|
||||
/** @param {number} userId */
|
||||
async getDailyShareCount(userId) {
|
||||
const { res } = await this.stores.kv.get({
|
||||
key: this.#dailyQuotaKey(userId),
|
||||
});
|
||||
const count = /** @type {{ count?: unknown } | null} */ (res)?.count;
|
||||
return typeof count === 'number' ? count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} userId
|
||||
* @param {number} [amount]
|
||||
* @returns {Promise<number>} The count after incrementing
|
||||
*/
|
||||
async incrementDailyShareCount(userId, amount = 1) {
|
||||
const { res } = await this.stores.kv.incr({
|
||||
key: this.#dailyQuotaKey(userId),
|
||||
pathAndAmountMap: { count: amount },
|
||||
// Two days, so a counter written just before midnight still ages
|
||||
// out on its own rather than lingering for the next reader.
|
||||
expireAt: Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60,
|
||||
});
|
||||
const count = /** @type {{ count?: unknown } | null} */ (res)?.count;
|
||||
return typeof count === 'number' ? count : amount;
|
||||
}
|
||||
|
||||
/** @param {number} userId */
|
||||
#dailyQuotaKey(userId) {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
return `share:quota:${userId}:${day}`;
|
||||
}
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
#normalizeRow(row) {
|
||||
|
||||
@@ -817,6 +817,21 @@ interface IConfigOptional {
|
||||
/** When true, ACL grants read/list/see on `/<user>/Public` to any actor. */
|
||||
enable_public_folders: boolean;
|
||||
|
||||
/**
|
||||
* Ceiling on how many shares one user may create per UTC day. An abuse
|
||||
* bound, not an accounting one — it exists so a script can't blanket other
|
||||
* accounts with unwanted items and the notifications that follow. Omit to
|
||||
* use the built-in default.
|
||||
*/
|
||||
share_daily_limit?: number;
|
||||
|
||||
/**
|
||||
* Ceiling on recipients, and on items, in a single share request. Bounds
|
||||
* the fan-out one call can trigger; the daily limit bounds the total.
|
||||
*/
|
||||
share_max_recipients?: number;
|
||||
share_max_items?: number;
|
||||
|
||||
// -- Storage / S3 ------------------------------------------------
|
||||
|
||||
/** S3 storage config (local fauxqs or remote). */
|
||||
|
||||
Reference in New Issue
Block a user