mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-30 09:06:37 +00:00
fix: keep blocks out of the authority graph, and sweep what unshare missed
Code review on the previous commit confirmed three ways the block filter inside readUserGroupPerms turned a reversible block into permanent loss: canManagePermission reads the same rows, so a block-suspended grant looked revoked to #revokeDownstream's cascade, to unshare's authority gate, and to invite claiming — deleting re-shares and invites that were supposed to come back on unblock. The scan is now deliberately blind to blocks; a block suspends delivery only (listing, count, fan-out, telling), which the share store filters itself through one shared notBlockedSql fragment owned by UserBlockStore. The team-unshare sweep also now covers what it claimed to: a member's re-share *to another team* is revoked with them (group rows swept in #revokeDownstream, user path included), and the sweep is skipped entirely while another issuer's grant still backs the team — members' re-shares rest on that authority and revoking them would orphan live access. The blanket block-all note in settings now says team shares still arrive, matching what the code deliberately does.
This commit is contained in:
@@ -1363,6 +1363,10 @@ export class ShareService extends PuterService {
|
||||
entry.id,
|
||||
);
|
||||
|
||||
// What they re-shared to teams goes too: a group grant left behind is
|
||||
// dormant, and springs back if the issuer ever requalifies.
|
||||
let revoked = await this.#revokeGroupSharesBy(actor, entry, issuerId);
|
||||
|
||||
// The whole subtree, not just this node: `manage` inherits downwards,
|
||||
// so a grant on a descendant can rest on authority held here.
|
||||
const rows = (
|
||||
@@ -1371,7 +1375,7 @@ export class ShareService extends PuterService {
|
||||
(row: { issuer_user_id: number }) =>
|
||||
Number(row.issuer_user_id) === issuerId,
|
||||
);
|
||||
if (rows.length === 0) return 0;
|
||||
if (rows.length === 0) return revoked;
|
||||
|
||||
const [nodes, holders] = await Promise.all([
|
||||
this.stores.fsEntry.getEntriesByIds(
|
||||
@@ -1386,7 +1390,6 @@ export class ShareService extends PuterService {
|
||||
),
|
||||
]);
|
||||
|
||||
let revoked = 0;
|
||||
for (const row of rows) {
|
||||
const holderId = Number(row.holder_user_id);
|
||||
const node = nodes.get(Number(row.fsentry_id));
|
||||
@@ -1428,6 +1431,61 @@ export class ShareService extends PuterService {
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/** Withdraw every team-held grant `issuerId` made in this subtree. */
|
||||
async #revokeGroupSharesBy(
|
||||
actor: Actor,
|
||||
entry: FSEntry,
|
||||
issuerId: number,
|
||||
): Promise<number> {
|
||||
const rows = (
|
||||
await this.stores.share.listGroupSharesBySubtree(entry.id)
|
||||
).filter(
|
||||
(row: { issuer_user_id: number }) =>
|
||||
Number(row.issuer_user_id) === issuerId,
|
||||
);
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
const nodes = await this.stores.fsEntry.getEntriesByIds(
|
||||
rows.map((row: { fsentry_id: number }) => Number(row.fsentry_id)),
|
||||
);
|
||||
let revoked = 0;
|
||||
for (const row of rows) {
|
||||
const node = nodes.get(Number(row.fsentry_id));
|
||||
// Deleted teams included: their grants are still revocable.
|
||||
const team = await this.stores.team.getByIdIncludingDeleted(
|
||||
Number(row.holder_group_id),
|
||||
);
|
||||
if (!node || !team) continue;
|
||||
for (const permission of entryPermissions(node.uuid)) {
|
||||
if (
|
||||
!(await this.services.permission.canManagePermission(
|
||||
actor,
|
||||
permission,
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
await this.services.permission.revokeUserGroupPermission(
|
||||
actor,
|
||||
team.uid,
|
||||
permission,
|
||||
{ reason: 'unshared' },
|
||||
{ issuerUserId: issuerId },
|
||||
)
|
||||
) {
|
||||
revoked++;
|
||||
}
|
||||
}
|
||||
await this.stores.share.deleteActiveGroup({
|
||||
holderGroupId: Number(row.holder_group_id),
|
||||
fsentryId: node.id,
|
||||
issuerUserId: issuerId,
|
||||
});
|
||||
}
|
||||
return revoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the grants pointing at a node that no longer exists. Returns the
|
||||
* rows removed, which is the only record of who had access — the index rows
|
||||
@@ -2209,11 +2267,11 @@ export class ShareService extends PuterService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse further shares from `username`. Existing direct shares stand:
|
||||
* access someone already has is theirs until it is withdrawn, and a control
|
||||
* 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. Team-delivered
|
||||
* shares are derived per member on every read, so those are suspended while
|
||||
* the block stands — nothing revoked, unblock restores.
|
||||
* items from this sender stop being listed, announced or pushed while the
|
||||
* block stands; the grants are untouched, so unblocking restores the view.
|
||||
*/
|
||||
async blockSender(
|
||||
actor: Actor,
|
||||
@@ -2230,8 +2288,6 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -2246,23 +2302,9 @@ 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<void> {
|
||||
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.
|
||||
@@ -2612,17 +2654,36 @@ export class ShareService extends PuterService {
|
||||
// Swept by the rows that exist, not by a capped member page.
|
||||
let revoked = 0;
|
||||
const issuerSet = new Set(issuers);
|
||||
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),
|
||||
// Sweep only when this call takes the team's last grant on the entry:
|
||||
// while another issuer's grant stands, members keep the very authority
|
||||
// their re-shares rest on, and revoking those would orphan live access.
|
||||
const teamStillGranted = (
|
||||
await this.stores.share.listGroupSharesByFsentry(entry.id)
|
||||
).some(
|
||||
(row: { holder_group_id: number; issuer_user_id: number }) =>
|
||||
Number(row.holder_group_id) === team.id &&
|
||||
!issuerSet.has(Number(row.issuer_user_id)),
|
||||
);
|
||||
for (const memberId of await this.stores.team.memberIdsAmong(
|
||||
team.uid,
|
||||
candidates,
|
||||
)) {
|
||||
revoked += await this.#revokeDownstream(me, entry, memberId);
|
||||
if (!teamStillGranted) {
|
||||
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),
|
||||
);
|
||||
// One walk: two members can have re-shared to each other.
|
||||
const seen = new Set<number>();
|
||||
for (const memberId of await this.stores.team.memberIdsAmong(
|
||||
team.uid,
|
||||
candidates,
|
||||
)) {
|
||||
revoked += await this.#revokeDownstream(
|
||||
me,
|
||||
entry,
|
||||
memberId,
|
||||
seen,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const permissions = entryPermissions(entry.uuid);
|
||||
|
||||
@@ -610,8 +610,10 @@ 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.
|
||||
// -- the recipient block list ---------------------------------------
|
||||
// A block suspends delivery only — listing, count, fan-out, telling. The
|
||||
// grant and the authority graph stay whole, or a reversible block turns
|
||||
// into permanent revocations downstream.
|
||||
|
||||
const block = (blocker: FixtureUser, blocked: FixtureUser) =>
|
||||
fx.env.server.stores.userBlock.create(blocker.userId, blocked.userId);
|
||||
@@ -642,13 +644,13 @@ describe('sharing with a team', () => {
|
||||
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.
|
||||
// The grant itself stands — nothing is revoked by a block.
|
||||
const perms =
|
||||
await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
blockingSeat.userId,
|
||||
[`fs:${file.uid}:read`],
|
||||
);
|
||||
expect(perms).toHaveLength(0);
|
||||
expect(perms).toHaveLength(1);
|
||||
|
||||
// The rest of the team is unaffected.
|
||||
const others = (await inbox(otherSeat.userId)).items;
|
||||
@@ -720,7 +722,38 @@ describe('sharing with a team', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// -- unshare sweeps by rows, not by a member page (PUT-1813) --------
|
||||
it('leaves a blocked member their authority, so they can still withdraw', 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',
|
||||
);
|
||||
await shares().share(await actorFor(seat.userId), {
|
||||
path: file.path,
|
||||
recipient: { username: fx.outsider.username },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
|
||||
await block(seat, fx.a.owner);
|
||||
try {
|
||||
// Authority must survive the block, or what they granted becomes
|
||||
// theirs to keep but not theirs to take back.
|
||||
await shares().unshare(await actorFor(seat.userId), {
|
||||
path: file.path,
|
||||
recipient: { username: fx.outsider.username },
|
||||
} as never);
|
||||
expect(
|
||||
(await inbox(fx.outsider.userId)).items.map((i) => i.entryUid),
|
||||
).not.toContain(file.uid);
|
||||
} finally {
|
||||
await unblock(seat, fx.a.owner);
|
||||
}
|
||||
});
|
||||
|
||||
// -- unshare sweeps by rows, not by a member page --------------------
|
||||
|
||||
it('sweeps a member re-share on team unshare', async () => {
|
||||
const seat = fx.a.seats[0];
|
||||
@@ -821,4 +854,77 @@ describe('sharing with a team', () => {
|
||||
expect(members).toContain(seat.userId);
|
||||
expect(members).not.toContain(fx.outsider.userId);
|
||||
});
|
||||
|
||||
it('sweeps a member re-share made to another team', async () => {
|
||||
const seat = fx.a.seats[0];
|
||||
// The seat belongs to both teams, so it may re-share A's file into B.
|
||||
await fx.env.server.stores.team.addMember(fx.b.uid, seat.userId, {
|
||||
orgOwned: false,
|
||||
});
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(
|
||||
fx.a.owner.userId,
|
||||
file.path,
|
||||
{ team: fx.a.uid },
|
||||
'manage',
|
||||
);
|
||||
await shares().share(await actorFor(seat.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.b.uid },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
expect(
|
||||
(await inbox(fx.b.seats[0].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);
|
||||
|
||||
// Left standing, team B's grant is dormant and springs back whenever
|
||||
// the seat requalifies — the row and the grant both have to go.
|
||||
expect(
|
||||
await fx.env.server.stores.permission.readUserGroupPerms(
|
||||
fx.b.seats[0].userId,
|
||||
[`fs:${file.uid}:read`],
|
||||
),
|
||||
).toHaveLength(0);
|
||||
const entry = await fx.env.server.stores.fsEntry.getEntryByUuid(
|
||||
file.uid,
|
||||
);
|
||||
expect(
|
||||
await fx.env.server.stores.share.listGroupSharesByFsentry(
|
||||
entry!.id,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps member re-shares while another issuer still grants the team', async () => {
|
||||
const [m1, m2] = fx.a.seats;
|
||||
const file = await makeFile(fx.a.owner.userId);
|
||||
await shareWithTeam(
|
||||
fx.a.owner.userId,
|
||||
file.path,
|
||||
{ team: fx.a.uid },
|
||||
'manage',
|
||||
);
|
||||
// Two grants back the team; m2's re-share rests on either of them.
|
||||
await shareWithTeam(m1.userId, file.path, { team: fx.a.uid });
|
||||
await shares().share(await actorFor(m2.userId), {
|
||||
path: file.path,
|
||||
recipient: { username: fx.outsider.username },
|
||||
mode: 'read',
|
||||
} as never);
|
||||
|
||||
// m1 withdraws only their own grant; the owner's still delivers.
|
||||
await shares().unshare(await actorFor(m1.userId), {
|
||||
path: file.path,
|
||||
recipient: { team: fx.a.uid },
|
||||
} as never);
|
||||
|
||||
expect(
|
||||
(await inbox(fx.outsider.userId)).items.map((i) => i.entryUid),
|
||||
).toContain(file.uid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ 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
|
||||
@@ -979,6 +978,9 @@ export class PermissionStore extends PuterStore {
|
||||
* Reads group permissions granted to groups the user is a member of, for a
|
||||
* given set of permission strings. Result already joined against
|
||||
* `jct_user_group` so callers don't need group membership resolution.
|
||||
* Deliberately blind to the block list: this reading also answers authority
|
||||
* checks that gate permanent revocations, and a block only suspends
|
||||
* delivery (which the share listings and fan-out filter themselves).
|
||||
*/
|
||||
async readUserGroupPerms(
|
||||
userId: number,
|
||||
@@ -993,27 +995,14 @@ 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} ` +
|
||||
this.#notBlockedByHolderSql(),
|
||||
[userId, ...permissions, TEAM_KIND],
|
||||
`WHERE ug.user_id = ? AND g.\`deleted_at\` IS NULL AND ${permClause}`,
|
||||
[userId, ...permissions],
|
||||
);
|
||||
return rows.map((row) =>
|
||||
this.#decodeExtra<LinkedUserGroupPermRow>(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<number | null> {
|
||||
const rows = (await this.clients.db.read(
|
||||
@@ -1062,9 +1051,8 @@ 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(', ')}) ` +
|
||||
this.#notBlockedByHolderSql(),
|
||||
[...holders, ...perms, TEAM_KIND],
|
||||
`AND p.\`permission\` IN (${perms.map(() => '?').join(', ')})`,
|
||||
[...holders, ...perms],
|
||||
);
|
||||
return rows as unknown as Array<{
|
||||
holder_user_id: number;
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { encodeCursor, decodeCursor } from '../../util/pagination';
|
||||
import { notBlockedSql } from '../userBlock/UserBlockStore';
|
||||
import { PuterStore } from '../types';
|
||||
|
||||
/** Default page size for the keyset listings. */
|
||||
@@ -309,12 +310,7 @@ export class ShareStore extends PuterStore {
|
||||
*/
|
||||
async listByFsentrySubtree(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`' +
|
||||
') ' +
|
||||
this.#subtreeCte() +
|
||||
'SELECT `share`.* FROM `share` ' +
|
||||
'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' +
|
||||
'WHERE `share`.`holder_user_id` IS NOT NULL ' +
|
||||
@@ -324,6 +320,24 @@ export class ShareStore extends PuterStore {
|
||||
return rows.map((r) => this.#normalizeRow(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Team-held rows on a directory or anything beneath it. What a revoked
|
||||
* issuer re-shared to _teams_; `listByFsentrySubtree` only sees holders.
|
||||
*
|
||||
* @param {number} fsentryId
|
||||
*/
|
||||
async listGroupSharesBySubtree(fsentryId) {
|
||||
const rows = await this.clients.db.read(
|
||||
this.#subtreeCte() +
|
||||
'SELECT `share`.* FROM `share` ' +
|
||||
'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' +
|
||||
'WHERE `share`.`holder_group_id` IS NOT NULL ' +
|
||||
'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.
|
||||
@@ -364,9 +378,7 @@ export class ShareStore extends PuterStore {
|
||||
`WHERE \`share\`.\`fsentry_id\` IN (${placeholders}) ` +
|
||||
'AND `share`.`holder_group_id` IS NOT NULL ' +
|
||||
'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`) ' +
|
||||
`AND ${notBlockedSql('`ug`.`user_id`', '`share`.`issuer_user_id`')} ` +
|
||||
'ORDER BY `share`.`id`',
|
||||
fsentryIds,
|
||||
);
|
||||
@@ -386,12 +398,7 @@ export class ShareStore extends PuterStore {
|
||||
*/
|
||||
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`' +
|
||||
') ' +
|
||||
this.#subtreeCte() +
|
||||
'SELECT DISTINCT `share`.`issuer_user_id` FROM `share` ' +
|
||||
'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id`',
|
||||
[fsentryId],
|
||||
@@ -836,12 +843,7 @@ export class ShareStore extends PuterStore {
|
||||
// dialects disagree on. The gap between the two only ever leaves an
|
||||
// invite standing, and the claim path re-checks authority anyway.
|
||||
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`' +
|
||||
') ' +
|
||||
this.#subtreeCte() +
|
||||
'SELECT `share`.`uid` FROM `share` ' +
|
||||
'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' +
|
||||
// Group rows also have no holder user; deleting one here would
|
||||
@@ -914,13 +916,21 @@ export class ShareStore extends PuterStore {
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
/** The recursive walk of a directory's row ids, by parent linkage. */
|
||||
#subtreeCte() {
|
||||
return (
|
||||
'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`' +
|
||||
') '
|
||||
);
|
||||
}
|
||||
|
||||
/** 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`)'
|
||||
);
|
||||
return notBlockedSql('?', '`share`.`issuer_user_id`');
|
||||
}
|
||||
|
||||
/** @param {number} [limit] */
|
||||
|
||||
@@ -19,6 +19,19 @@
|
||||
|
||||
import { PuterStore } from '../types';
|
||||
|
||||
/**
|
||||
* SQL fragment: true when `blockerExpr` has no block against `blockedExpr`. The
|
||||
* one spelling of the rule, so every filtered surface stays in step. Exprs are
|
||||
* SQL (a column or a `?`), never user input.
|
||||
*/
|
||||
export const notBlockedSql = (
|
||||
blockerExpr: string,
|
||||
blockedExpr: string,
|
||||
): string =>
|
||||
'NOT EXISTS (SELECT 1 FROM `user_block` `ub` ' +
|
||||
`WHERE \`ub\`.\`blocker_user_id\` = ${blockerExpr} ` +
|
||||
`AND \`ub\`.\`blocked_user_id\` = ${blockedExpr})`;
|
||||
|
||||
/** One row of `user_block`. `created_at` is unix seconds. */
|
||||
export interface UserBlockRow {
|
||||
id: number;
|
||||
|
||||
@@ -35,7 +35,7 @@ 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.
|
||||
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, announced, or pushed to them while their block stands; the grant itself is untouched, so lifting the block restores their view without a re-share. The rest of the team is unaffected, and nothing tells the sharer. The blanket "block everyone" switch does not extend to teams the recipient belongs to — blocking the sender, or leaving the team, is what stops those.
|
||||
|
||||
#### `mode` (String) (optional)
|
||||
|
||||
|
||||
@@ -61,8 +61,9 @@ 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.
|
||||
stands, nothing that sharer puts into the team is listed or announced to that
|
||||
member, and the sharer is not told. The underlying grant is untouched, so
|
||||
lifting the block restores the member's view.
|
||||
|
||||
See [`puter.fs.share()`](/FS/share/) for the full sharing API.
|
||||
|
||||
|
||||
@@ -480,7 +480,7 @@ const en = {
|
||||
'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.',
|
||||
'Refuses every new share, whoever it’s from. What’s already shared with you stays, and shares made to a team you belong to still arrive — block the sender, or leave the team.',
|
||||
blocked_all_on: 'New shares are now refused from everyone',
|
||||
blocked_all_off: 'You’re accepting shares again',
|
||||
blocked_add: 'Block someone',
|
||||
|
||||
Reference in New Issue
Block a user