From 9da03555ae39b26281e6cfeda00dffa63b4f7744 Mon Sep 17 00:00:00 2001 From: Juan Castro Date: Tue, 15 Sep 2026 12:58:07 -0400 Subject: [PATCH] fix: make the block list bite on team shares, and unshare every re-share (PUT-1813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sender a member blocked could still land items in that member's shared-with-me — and grant them real access — by sharing with a common team. The group grant is one row and cannot exclude a member, so the block is now enforced where delivery is derived per member: the permission scan, the inbound listing and its count, and the fs-event fan-out all skip team rows whose issuer the member blocked. Nothing is revoked, so unblocking restores everything. Blocking now also bumps the blocker's permission cache so it bites immediately. Direct shares keep their contract: existing ones stand. #unshareTeam swept re-shares by listing members with a 200 cap and never following the cursor, so members past the cap kept their orphaned rows. It now walks the share rows in the subtree — the set that can actually need sweeping — and filters those issuers to members, which has no cap by construction. --- src/backend/services/share/ShareService.ts | 49 +++- src/backend/services/share/TeamShare.test.ts | 238 +++++++++++++++++- .../stores/permission/PermissionStore.ts | 23 +- src/backend/stores/share/ShareStore.js | 59 ++++- src/backend/stores/team/TeamStore.ts | 20 ++ src/docs/src/FS/share.md | 2 + src/docs/src/Teams.md | 4 + src/gui/src/i18n/translations/en.js | 2 +- 8 files changed, 366 insertions(+), 31 deletions(-) diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index aaaa9078a..1d7aa8ae3 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -30,7 +30,7 @@ import { } from '../../util/email.js'; import type { FSEntry } from '../../stores/fs/FSEntry'; import type { UserUserAuditFilter } from '../../stores/permission/PermissionStore'; -import { MEMBER_PAGE_CAP, type TeamRow } from '../../stores/team/TeamStore'; +import { type TeamRow } from '../../stores/team/TeamStore'; import type { UserRow } from '../../stores/user/UserStore'; import type { AclMode } from '../acl/ACLService'; import { @@ -2193,7 +2193,9 @@ export class ShareService extends PuterService { * asking the user to rebuild it. * * `updateMetadata` merges rather than replaces, and refreshes the cached - * row, so the switch bites on the very next share. + * row, so the switch bites on the very next share. Deliberately does not + * gate team-delivered shares; blocking the sender, or leaving the team, + * does. */ async setBlockAllSenders( actor: Actor, @@ -2207,9 +2209,11 @@ export class ShareService extends PuterService { } /** - * Refuse further shares from `username`. Existing shares stand: access - * someone already has is theirs until it is withdrawn, and a control - * labelled "block" silently revoking it would be a surprise. + * Refuse further shares from `username`. Existing direct shares stand: + * access someone already has is theirs until it is withdrawn, and a control + * labelled "block" silently revoking it would be a surprise. Team-delivered + * shares are derived per member on every read, so those are suspended while + * the block stands — nothing revoked, unblock restores. */ async blockSender( actor: Actor, @@ -2226,6 +2230,8 @@ export class ShareService extends PuterService { blockerId, target.id, ); + // Group-delivered access changed, so cached scans must not outlive it. + if (created) await this.#bumpBlockerCache(actor); return { username: target.username as string, created }; } @@ -2240,9 +2246,23 @@ export class ShareService extends PuterService { blockerId, target.id, ); + if (unblocked) await this.#bumpBlockerCache(actor); return { username: target.username as string, unblocked }; } + /** A block gates team-delivered access, so it has to bite immediately. */ + async #bumpBlockerCache(actor: Actor): Promise { + const username = actor.user?.username; + if (!username) return; + try { + await this.services.permission.bumpPermissionCacheForUsernames([ + username, + ]); + } catch { + // The TTL still bounds a stale reading; the block itself is saved. + } + } + /** * Who the caller refuses shares from, and whether they refuse everyone. * Usernames only — ids aren't theirs. @@ -2589,16 +2609,19 @@ export class ShareService extends PuterService { // Whatever a member re-shared goes with them, as on the user path, and // first: clearing the group grant would strip the `manage` it needs. + // Swept by the rows that exist, not by a capped member page. let revoked = 0; - const members = await this.stores.team.listMembers(team.uid, { - limit: MEMBER_PAGE_CAP, - }); const issuerSet = new Set(issuers); - for (const member of members.items) { - const memberId = Number(member.user_id); - // 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; + const candidates = ( + await this.stores.share.listIssuerIdsBySubtree(entry.id) + ).filter( + // The owner and the issuers are handled by the revoke loop below. + (id: number) => id !== entry.userId && !issuerSet.has(id), + ); + for (const memberId of await this.stores.team.memberIdsAmong( + team.uid, + candidates, + )) { revoked += await this.#revokeDownstream(me, entry, memberId); } diff --git a/src/backend/services/share/TeamShare.test.ts b/src/backend/services/share/TeamShare.test.ts index 5f1b0a212..7e29742e0 100644 --- a/src/backend/services/share/TeamShare.test.ts +++ b/src/backend/services/share/TeamShare.test.ts @@ -21,6 +21,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { Actor } from '../../core/actor'; import { setupTwoTeams, + type FixtureUser, type TwoTeams, } from '../../testFixtures/twoTeams.js'; @@ -55,16 +56,22 @@ describe('sharing with a team', () => { return { path, uid }; }; - /** A directory and a file inside it, both as real fsentry rows. */ + /** A directory and a file inside it, parent-linked as real rows are. */ const makeNestedFile = async (ownerId: number) => { const owner = await fx.env.server.stores.user.getById(ownerId); const dirUid = crypto.randomUUID(); const dirName = `d_${dirUid.slice(0, 8)}`; const dirPath = `/${owner!.username}/${dirName}`; - const insert = (uid: string, name: string, path: string, isDir: boolean) => + const insert = ( + uid: string, + name: string, + path: string, + isDir: boolean, + parentId: number | null = null, + ) => fx.env.server.clients.db.write( - 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' + - 'VALUES (?, ?, ?, ?, ?, ?)', + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?)', [ uid, name, @@ -72,12 +79,21 @@ describe('sharing with a team', () => { ownerId, fx.env.server.clients.db.booleanValue(isDir), Math.floor(Date.now() / 1000), + parentId, ], ); await insert(dirUid, dirName, dirPath, true); + const dirEntry = + await fx.env.server.stores.fsEntry.getEntryByUuid(dirUid); const fileUid = crypto.randomUUID(); const fileName = `f_${fileUid.slice(0, 8)}.txt`; - await insert(fileUid, fileName, `${dirPath}/${fileName}`, false); + await insert( + fileUid, + fileName, + `${dirPath}/${fileName}`, + false, + dirEntry!.id, + ); return { dirPath, dirUid, fileUid }; }; @@ -593,4 +609,216 @@ describe('sharing with a team', () => { ); expect(rows.some((r) => r.holderTeam?.uid === fx.a.uid)).toBe(true); }); + + // -- the recipient block list (PUT-1813) ---------------------------- + // Enforced where delivery is derived per member: listing, grant, fan-out. + + const block = (blocker: FixtureUser, blocked: FixtureUser) => + fx.env.server.stores.userBlock.create(blocker.userId, blocked.userId); + const unblock = (blocker: FixtureUser, blocked: FixtureUser) => + fx.env.server.stores.userBlock.deleteByPair( + blocker.userId, + blocked.userId, + ); + + it('does not deliver a team share to a member who blocked the sender', async () => { + const [blockingSeat, otherSeat] = fx.a.seats; + await block(blockingSeat, fx.a.owner); + try { + const before = await shares().listSharedWithMe( + await actorFor(blockingSeat.userId), + { limit: 100, includeTotal: true }, + ); + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { + team: fx.a.uid, + }); + + // Neither the listing entry nor the count moves for the blocker. + const after = await shares().listSharedWithMe( + await actorFor(blockingSeat.userId), + { limit: 100, includeTotal: true }, + ); + expect(after.items.map((i) => i.entryUid)).not.toContain(file.uid); + expect(after.total).toBe(before.total); + + // The block refuses access, not just the listing row. + const perms = + await fx.env.server.stores.permission.readUserGroupPerms( + blockingSeat.userId, + [`fs:${file.uid}:read`], + ); + expect(perms).toHaveLength(0); + + // The rest of the team is unaffected. + const others = (await inbox(otherSeat.userId)).items; + expect(others.map((i) => i.entryUid)).toContain(file.uid); + } finally { + await unblock(blockingSeat, fx.a.owner); + } + }); + + it('suspends delivery on block and restores it on unblock', async () => { + const seat = fx.a.seats[0]; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); + expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( + file.uid, + ); + + await block(seat, fx.a.owner); + try { + expect( + (await inbox(seat.userId)).items.map((i) => i.entryUid), + ).not.toContain(file.uid); + } finally { + await unblock(seat, fx.a.owner); + } + + // Nothing was revoked, so lifting the block needs no re-share. + expect((await inbox(seat.userId)).items.map((i) => i.entryUid)).toContain( + file.uid, + ); + }); + + it('keeps a blocked member out of the live-event fan-out', async () => { + const [blockingSeat, otherSeat] = fx.a.seats; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam(fx.a.owner.userId, file.path, { team: fx.a.uid }); + const entry = await fx.env.server.stores.fsEntry.getEntryByUuid( + file.uid, + ); + + await block(blockingSeat, fx.a.owner); + try { + // Pushing changes to them would tell them what the block hides. + const rows = + await fx.env.server.stores.share.listGroupReachingMembers([ + entry.id, + ]); + const reached = rows.map((r) => Number(r.holder_user_id)); + expect(reached).not.toContain(blockingSeat.userId); + expect(reached).toContain(otherSeat.userId); + } finally { + await unblock(blockingSeat, fx.a.owner); + } + }); + + it('only suspends the blocked pair, not the member\'s other shares', async () => { + const seat = fx.a.seats[0]; + const peerFile = await makeFile(fx.a.seats[1].userId); + await shareWithTeam(fx.a.seats[1].userId, peerFile.path, { + team: fx.a.uid, + }); + + await block(seat, fx.a.owner); + try { + const items = (await inbox(seat.userId)).items; + expect(items.map((i) => i.entryUid)).toContain(peerFile.uid); + } finally { + await unblock(seat, fx.a.owner); + } + }); + + // -- unshare sweeps by rows, not by a member page (PUT-1813) -------- + + it('sweeps a member re-share on team unshare', async () => { + const seat = fx.a.seats[0]; + const file = await makeFile(fx.a.owner.userId); + // `manage` is what lets a member re-share in the first place. + await shareWithTeam( + fx.a.owner.userId, + file.path, + { team: fx.a.uid }, + 'manage', + ); + await shares().share(await actorFor(seat.userId), { + path: file.path, + recipient: { username: fx.outsider.username }, + mode: 'read', + } as never); + expect( + (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), + ).toContain(file.uid); + + await shares().unshare(await actorFor(fx.a.owner.userId), { + path: file.path, + recipient: { team: fx.a.uid }, + } as never); + + // The re-share rested on the group grant and goes with it. + expect( + (await inbox(fx.outsider.userId)).items.map((i) => i.entryUid), + ).not.toContain(file.uid); + }); + + it('sweeps a member\'s unclaimed invite on team unshare', async () => { + const seat = fx.a.seats[0]; + const file = await makeFile(fx.a.owner.userId); + await shareWithTeam( + fx.a.owner.userId, + file.path, + { team: fx.a.uid }, + 'manage', + ); + const invited = `invitee_${Math.random().toString(36).slice(2, 8)}@test.local`; + await shares().share(await actorFor(seat.userId), { + path: file.path, + recipient: { email: invited }, + mode: 'read', + } as never); + expect( + await fx.env.server.stores.share.listPendingByEmail(invited), + ).toHaveLength(1); + + await shares().unshare(await actorFor(fx.a.owner.userId), { + path: file.path, + recipient: { team: fx.a.uid }, + } as never); + + // The invite rests on the same authority the unshare withdrew. + expect( + await fx.env.server.stores.share.listPendingByEmail(invited), + ).toHaveLength(0); + }); + + it('finds every issuer in the subtree, and filters them to members', async () => { + const { dirPath, dirUid, fileUid } = await makeNestedFile( + fx.a.owner.userId, + ); + const seat = fx.a.seats[0]; + await shareWithTeam( + fx.a.owner.userId, + dirPath, + { team: fx.a.uid }, + 'manage', + ); + // A re-share on the nested file, visible only if the walk descends. + const fileEntry = await fx.env.server.stores.fsEntry.getEntryByUuid( + fileUid, + ); + await fx.env.server.stores.share.upsertActive({ + issuerUserId: seat.userId, + holderUserId: fx.outsider.userId, + fsentryId: fileEntry!.id, + mode: 'read', + }); + + const dirEntry = await fx.env.server.stores.fsEntry.getEntryByUuid( + dirUid, + ); + const issuers = await fx.env.server.stores.share.listIssuerIdsBySubtree( + dirEntry!.id, + ); + expect(issuers).toContain(fx.a.owner.userId); + expect(issuers).toContain(seat.userId); + + // The outsider issued nothing and is no member; both fall out here. + const members = await fx.env.server.stores.team.memberIdsAmong( + fx.a.uid, + [...issuers, fx.outsider.userId], + ); + expect(members).toContain(seat.userId); + expect(members).not.toContain(fx.outsider.userId); + }); }); diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 571238745..e93daa806 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -29,6 +29,7 @@ import { } from '../../services/permission/consts'; import { kv } from '../../util/kvSingleton'; import { decodeCursor, encodeCursor } from '../../util/pagination'; +import { TEAM_KIND } from '../team/TeamStore'; import type { UserRow } from '../user/UserStore'; // Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters @@ -992,14 +993,27 @@ export class PermissionStore extends PuterStore { 'SELECT p.permission, p.user_id, p.group_id, p.extra FROM `user_to_group_permissions` p ' + 'JOIN `jct_user_group` ug ON p.group_id = ug.group_id ' + 'JOIN `group` g ON g.`id` = ug.group_id ' + - `WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause}`, - [userId, ...permissions], + `WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause} ` + + this.#notBlockedByHolderSql(), + [userId, ...permissions, TEAM_KIND], ); return rows.map((row) => this.#decodeExtra(row), ); } + /** + * Skips team rows whose issuer the member blocked; seeded groups (NULL + * kind) untouched. + */ + #notBlockedByHolderSql(): string { + return ( + 'AND (g.`kind` IS NULL OR g.`kind` <> ? OR NOT EXISTS (' + + 'SELECT 1 FROM `user_block` ub WHERE ub.`blocker_user_id` = ug.`user_id` ' + + 'AND ub.`blocked_user_id` = p.`user_id`))' + ); + } + /** Any group, seeded or team: a grant does not care which kind it is. */ async resolveGroupId(groupUid: string): Promise { const rows = (await this.clients.db.read( @@ -1048,8 +1062,9 @@ export class PermissionStore extends PuterStore { 'JOIN `group` g ON g.`id` = ug.`group_id` ' + `WHERE ug.\`user_id\` IN (${holders.map(() => '?').join(', ')}) ` + 'AND g.`deleted_at` IS NULL ' + - `AND p.\`permission\` IN (${perms.map(() => '?').join(', ')})`, - [...holders, ...perms], + `AND p.\`permission\` IN (${perms.map(() => '?').join(', ')}) ` + + this.#notBlockedByHolderSql(), + [...holders, ...perms, TEAM_KIND], ); return rows as unknown as Array<{ holder_user_id: number; diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index da40456ec..afa8db484 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -86,18 +86,22 @@ export class ShareStore extends PuterStore { const afterId = this.#afterId(cursor); const groups = [...new Set(groupIds)].filter(Boolean); - // Same keyset page: `ORDER BY id` holds whatever the holder is. + // Same keyset page: `ORDER BY id` holds whatever the holder is. The + // group arm skips issuers this holder blocked. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}))` + .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` : '`holder_user_id` = ?'; + const holderParams = groups.length + ? [holderUserId, ...groups, holderUserId] + : [holderUserId]; // One extra row tells us whether another page exists. const rows = await this.clients.db.read( `SELECT * FROM \`share\` WHERE ${holderClause} AND \`id\` > ? ` + 'ORDER BY `id` LIMIT ?', - [holderUserId, ...groups, afterId, size + 1], + [...holderParams, afterId, size + 1], ); const hasMore = rows.length > size; @@ -352,13 +356,18 @@ export class ShareStore extends PuterStore { async listGroupReachingMembers(fsentryIds) { if (fsentryIds.length === 0) return []; const placeholders = fsentryIds.map(() => '?').join(', '); + // A member who blocked the issuer is not pushed that issuer's shares. const rows = await this.clients.db.read( 'SELECT `share`.*, `ug`.`user_id` AS `member_user_id` FROM `share` ' + 'JOIN `jct_user_group` `ug` ON `ug`.`group_id` = `share`.`holder_group_id` ' + 'JOIN `group` `g` ON `g`.`id` = `share`.`holder_group_id` ' + `WHERE \`share\`.\`fsentry_id\` IN (${placeholders}) ` + 'AND `share`.`holder_group_id` IS NOT NULL ' + - 'AND `g`.`deleted_at` IS NULL ORDER BY `share`.`id`', + 'AND `g`.`deleted_at` IS NULL ' + + 'AND NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + + 'WHERE `ub`.`blocker_user_id` = `ug`.`user_id` ' + + 'AND `ub`.`blocked_user_id` = `share`.`issuer_user_id`) ' + + 'ORDER BY `share`.`id`', fsentryIds, ); // Shaped as a holder row, so the caller's fan-out needs no group branch. @@ -368,6 +377,28 @@ export class ShareStore extends PuterStore { })); } + /** + * Everyone who issued any share row (active, pending or team-held) on a + * directory or anything beneath it. + * + * @param {number} fsentryId + * @returns {Promise} + */ + async listIssuerIdsBySubtree(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 DISTINCT `share`.`issuer_user_id` FROM `share` ' + + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id`', + [fsentryId], + ); + return rows.map((row) => Number(row.issuer_user_id)); + } + /** * Which of `fsentryIds` carry a share, pending invites included. * @@ -401,14 +432,17 @@ export class ShareStore extends PuterStore { */ async countByHolder(holderUserId, { groupIds = [] } = {}) { const groups = [...new Set(groupIds)].filter(Boolean); + // Group arm filtered as `listByHolder` is, or the total overcounts. const holderClause = groups.length - ? `(\`holder_user_id\` = ? OR \`holder_group_id\` IN (${groups + ? `(\`holder_user_id\` = ? OR (\`holder_group_id\` IN (${groups .map(() => '?') - .join(', ')}))` + .join(', ')}) AND ${this.#issuerNotBlockedSql()}))` : '`holder_user_id` = ?'; const rows = await this.clients.db.read( `SELECT COUNT(*) AS \`count\` FROM \`share\` WHERE ${holderClause}`, - [holderUserId, ...groups], + groups.length + ? [holderUserId, ...groups, holderUserId] + : [holderUserId], ); return Number(rows[0]?.count ?? 0); } @@ -880,6 +914,15 @@ export class ShareStore extends PuterStore { // -- Internals ---------------------------------------------------- + /** Group rows only exist for teams, so no kind guard is needed here. */ + #issuerNotBlockedSql() { + return ( + 'NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' + + 'WHERE `ub`.`blocker_user_id` = ? ' + + 'AND `ub`.`blocked_user_id` = `share`.`issuer_user_id`)' + ); + } + /** @param {number} [limit] */ #pageSize(limit) { return Math.min( diff --git a/src/backend/stores/team/TeamStore.ts b/src/backend/stores/team/TeamStore.ts index c26a90e74..f30f52026 100644 --- a/src/backend/stores/team/TeamStore.ts +++ b/src/backend/stores/team/TeamStore.ts @@ -367,6 +367,26 @@ export class TeamStore extends PuterStore { return (await this.getMembership(teamUid, userId)) !== null; } + /** + * Which of `userIds` belong to this team; bounded by its input, no page + * cap. + */ + async memberIdsAmong( + teamUid: string, + userIds: number[], + ): Promise { + const ids = [...new Set(userIds)].filter((id) => Number.isFinite(id)); + if (ids.length === 0) return []; + const rows = (await this.clients.db.read( + 'SELECT ug.`user_id` FROM `jct_user_group` ug ' + + 'JOIN `group` g ON g.`id` = ug.`group_id` ' + + `WHERE g.\`uid\` = ? AND g.${this.#live()} ` + + `AND ug.\`user_id\` IN (${ids.map(() => '?').join(', ')})`, + [teamUid, TEAM_KIND, ...ids], + )) as { user_id: number }[]; + return rows.map((row) => Number(row.user_id)); + } + /** A team's members, keyset-paginated on `id` per doc/pagination.md. */ async listMembers( teamUid: string, diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index 3eccff753..2f5a61827 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -35,6 +35,8 @@ Who to share with. A string containing `@` is treated as an email address, and a Where the deployment has [Teams](/Teams/), pass `{ team: uid }` to share with every member of a team the caller belongs to — including anyone added to it later. There is no string form for a team: a bare string is always read as an email or username. +A team share is never refused for one member's sake, so it does not produce `recipient_not_accepting_shares` — but a member who has blocked the sharer is not reached by it. Nothing you share with the team is listed for them, accessible to them, or announced to them while their block stands; the rest of the team is unaffected, and nothing tells the sharer. + #### `mode` (String) (optional) How much access to grant. Defaults to `'read'`. diff --git a/src/docs/src/Teams.md b/src/docs/src/Teams.md index a73ad27f3..3dcb0b21e 100644 --- a/src/docs/src/Teams.md +++ b/src/docs/src/Teams.md @@ -60,6 +60,10 @@ including anyone added later. Pass the team's `uid` as the recipient: await puter.fs.share({ path, recipient: { team: team.uid }, mode: 'read' }); ``` +A member who has blocked the sharer is the one exception: while the block +stands, that member neither sees nor can open anything the sharer put into the +team, and the sharer is not told. + See [`puter.fs.share()`](/FS/share/) for the full sharing API. ## Pagination diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 97fb5a57e..9028201a2 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -477,7 +477,7 @@ const en = { blocked_senders: 'Blocked people', blocked_senders_summary: 'People who can’t share with you', blocked_senders_note: - 'Blocked people can’t share anything new with you. What they already shared stays until you remove it.', + 'Blocked people can’t share anything new with you, and anything they share through a team you’re in stays hidden from you. What they already shared directly stays until you remove it.', blocked_all: 'Don’t let anyone share with me', blocked_all_note: 'Refuses every new share, whoever it’s from. What’s already shared with you stays.',