Files
puter/src/backend/services/share/ShareService.ts
T
Daniel Salazar 1736d23ee7
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Ds/put 1674 (#3715)
* feat: app-scoped share listing and revoke surface (PUT-1670)

* fix: address review on app-scoped share surface

- Scope the uid-addressed revoke to the named row: only that row's
  issuer's grant is withdrawn, and only that one invite cancelled —
  an app or owner addressing one row no longer takes another issuer's
  grant on the same (item, recipient) pair with it.
- Delete a pending row directly on uid-addressed revoke, so an invite
  whose address registered but never claimed can still be withdrawn.
- Read the legacy `issuerAppUid` data key in the SQL app filter and
  grouping, alongside the unified `issuedByApp`.
- Refuse malformed `appUid` input (duplicated param, empty string)
  instead of silently listing everything, and refuse app-listing
  cursors that decode but name no appUid.
- Derive the acting app from `effectiveApp` alone, per the actor
  contract; drop the second derivation site.
- Pin the attribution semantics with tests: one row records one
  issuance, so re-sharing the same pair re-attributes it to whoever
  issued last, in both directions.
- Soften the uniform-404 docblocks to what the gates actually answer.

* feat: readable grant audit trail (PUT-1674)

* fix: cover the apps summary's no-app-group first page (PUT-1670)

listOutboundApps sorts the no-app group first via an empty-string
sentinel. Add a regression test pinning that a first page (no cursor)
actually returns it, and that the cursor it hands back resumes past it
into the app-keyed groups rather than skipping or repeating.
2026-09-01 14:07:15 -07:00

2758 lines
105 KiB
TypeScript

/*
* 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 { contentType as contentTypeFromMime } from 'mime-types';
import { posix as pathPosix } from 'node:path';
import { userRelatedActor, type Actor } from '../../core/actor';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import { isUniqueViolation } from '../../util/dbError.js';
import {
abuseKey,
cleanEmail,
isProviderCanonicalized,
} from '../../util/email.js';
import type { FSEntry } from '../../stores/fs/FSEntry';
import type { UserUserAuditFilter } from '../../stores/permission/PermissionStore';
import type { UserRow } from '../../stores/user/UserStore';
import type { AclMode } from '../acl/ACLService';
import {
learnShareRoots,
maskEntryPath,
resolveSharePath,
} from '../fs/sharePathMask';
import { MANAGE_PERM_PREFIX } from '../permission/consts';
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;
}
/** A `share` row, as much of it as this service reads back. */
interface ShareIndexRow {
uid: string;
mode: string;
holder_user_id: number;
issuer_user_id: number;
fsentry_id: number;
created_at?: unknown;
data?: unknown;
}
/**
* As above, from a listing that carries unclaimed invites: those have no
* holder.
*/
interface OutboundShareRow extends Omit<ShareIndexRow, 'holder_user_id'> {
holder_user_id: number | null;
recipient_email?: string;
}
export interface ShareInput extends ShareTarget {
recipient: ShareRecipient;
mode: AclMode;
}
/** What still backs one (holder, entry) pair; see `#grantEvidence`. */
interface GrantEvidence {
/** Issuers with a live, attributable grant. */
issuers: Set<number>;
/** A live grant that names no issuer — a legacy flat entry. */
unattributed: boolean;
/** The holder owns the entry outright, so no grant is needed. */
owned: boolean;
}
/**
* One entry in the grant audit trail. Written when a grant is made or
* withdrawn, and kept afterwards — the row is what remains once the grant
* itself is gone.
*/
export interface GrantAuditEntry {
/** 'grant' or 'revoke'; null on a row that predates the column. */
action: string | null;
permission: string;
/** Set when the grant names an fs node; other permissions carry neither. */
entryUid: string | null;
mode: string | null;
issuer: { username: string | null };
holder: { username: string | null };
/** The app that was acting, when one was. */
appUid: string | null;
createdAt: unknown;
}
/** One app in the outbound listing's group-by-app view. */
export interface OutboundAppSummary {
/** Null for the grants the user made themselves, outside any app. */
appUid: string | null;
/** Null once the app is gone; its grants outlive it. */
name: string | null;
title: string | null;
count: number;
}
/** One live share, resolved for a response. */
export interface ResolvedShare {
uid: string;
mode: string;
path: string;
/**
* The entry's own name, content type and thumbnail. A share has no fsentry
* behind it for the client to stat for them, and the masked path hides
* which folder it sits in. `type` and `thumbnail` come with a listing
* only.
*/
name?: string;
type?: string | null;
thumbnail?: string | null;
entryUid: string;
isDir: boolean;
/** Whose entry it is. */
owner?: { username: string | null };
issuer: { username: string | null };
holder: { username: string | null };
createdAt: unknown;
/** Set when the access comes from a shared ancestor, not this node. */
inheritedFrom?: string | null;
/** The app that asked for this share, when one did. */
issuedByApp?: string | null;
modified: number;
size: number | null;
/** Set by `share()` only: who to notify. Never sent to a client. */
holderId?: number;
/** Whether this call created reach that didn't exist before. */
isNew?: boolean;
/**
* An invite to an address with no confirmed account. No grant exists yet —
* it is written when the recipient confirms the address.
*/
pending?: boolean;
/** Address the invite was aimed at. Set only when `pending`. */
recipientEmail?: string;
}
const SHAREABLE_MODES: ReadonlySet<string> = new Set([
'see',
'list',
'read',
'write',
'manage',
]);
/**
* Every permission a share of one node can rest on. `manage` is spelled with
* the prefix leading, so a prefix match on `fs:<uuid>` does not reach it.
*/
/**
* The app recorded on a share row, when one issued it. Two spellings in the
* wild: pending rows were written with `issuerAppUid` before the keys were
* unified on `issuedByApp`, and claiming carries `data` forward verbatim.
*/
const issuedByApp = (row: { data?: unknown }): string | null => {
const data = row.data as {
issuedByApp?: unknown;
issuerAppUid?: unknown;
} | null;
const value = data?.issuedByApp ?? data?.issuerAppUid;
return typeof value === 'string' && value !== '' ? value : null;
};
export const entryPermissions = (uuid: string): string[] => [
`fs:${uuid}:see`,
`fs:${uuid}:list`,
`fs:${uuid}:read`,
`fs:${uuid}:write`,
`manage:fs:${uuid}`,
];
/**
* The permission a share of `mode` actually grants — which is what authority to
* issue that share has to be measured against.
*/
export const entryPermissionForMode = (
uuid: string,
mode: AclMode | string,
): string =>
mode === MANAGE_PERM_PREFIX
? `${MANAGE_PERM_PREFIX}:fs:${uuid}`
: `fs:${uuid}:${mode}`;
/**
* The entry a grant names, read back out of the permission text.
*
* `fs:<uuid>[:mode]` and `manage:fs:<uuid>` are the only two shapes this
* service writes; anything else belongs to another domain and is not ours to
* interpret.
*/
export const uuidFromEntryPermission = (permission: string): string | null => {
const parts = permission.split(':');
const fsAt = parts[0] === MANAGE_PERM_PREFIX ? 1 : 0;
if (parts[fsAt] !== 'fs') return null;
return parts[fsAt + 1] || null;
};
/** The share mode a grant stands for, for the two shapes above. */
export const modeFromEntryPermission = (permission: string): string | null => {
if (!uuidFromEntryPermission(permission)) return null;
const parts = permission.split(':');
return parts[0] === MANAGE_PERM_PREFIX
? MANAGE_PERM_PREFIX
: (parts[2] ?? null);
};
/**
* How many entries' grants one retire query covers. Each entry contributes two
* indexed range scans (`fs:<uuid>` and `manage:fs:<uuid>`), so this bounds the
* work per statement while still collapsing a deleted subtree into a few round
* trips instead of one per descendant.
*/
const RETIRE_CHUNK_SIZE = 100;
/** Shares one user may create per UTC day, absent a config override. */
export const DEFAULT_DAILY_SHARE_LIMIT = 200;
/**
* The least an address must look like before an invite row is written for it.
* Deliverability is the inbox's business, but `a@b` or a pasted sentence must
* not become a permanent pending share that spent quota.
*/
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
/**
* Where "refuse shares from everyone" lives on the user row.
*
* A key in the existing `metadata` blob rather than a column of its own: the
* share path already holds the recipient's row by the time it asks, so reading
* it costs nothing either way, and a one-bit preference doesn't earn a
* migration per dialect.
*/
const BLOCK_ALL_SHARES_KEY = 'blockAllShares';
/** Whether this account refuses shares from everyone. */
const blocksAllShares = (user: Pick<UserRow, 'metadata'> | null): boolean =>
Boolean(user?.metadata?.[BLOCK_ALL_SHARES_KEY]);
/**
* What a share recipient's browser is told about someone else's entry.
*
* Curated rather than the row: the row carries the owner's real path, their
* numeric id, storage internals and the capability tokens — none of which are a
* recipient's to see. The path is masked at `root`, the share they reach it
* through, so it matches what their own reads returned.
*/
const holderPayload = (
entry: FSEntry,
/** Defaults to the entry itself, for the paths with no root to mask by. */
root: FSEntry = entry,
/** Where the entry was; differs from `entry.path` once it has moved. */
realPath: string = entry.path,
): Record<string, unknown> => {
const path =
maskedPathVia(root, realPath) ?? maskedSelfPath(entry, realPath);
return {
uid: entry.uuid,
uuid: entry.uuid,
name: entry.name,
path,
// The desktop finds the container to render into by `dirpath`.
dirpath: pathPosix.dirname(path),
is_dir: Boolean(entry.isDir),
type: entry.isDir ? 'folder' : contentTypeFromMime(entry.name) || null,
immutable: Boolean(entry.immutable),
size: entry.size ?? null,
modified: entry.modified,
from_new_service: true,
};
};
/**
* An `fs.*` event replayed onto this bus by replication rather than raised by a
* write here. Broadcast only carries `outer.*` and `pubsub.*`, so today nothing
* reaches these handlers that way — but the node that did the write has already
* told the audience, and a second fan-out would only duplicate it.
*/
const fromAnotherNode = (meta?: { from_outside?: boolean }): boolean =>
Boolean(meta?.from_outside);
/** The GUI events a share recipient is an audience for. */
type HolderGuiEvent =
| 'outer.gui.item.added'
| 'outer.gui.item.moved'
| 'outer.gui.item.removed'
| 'outer.gui.item.renamed'
| 'outer.gui.item.updated';
/** `/<owner>/<uuid>/<name>` for a path in the owner's tree. */
/** Inside some owner's top-level Trash, which is where Delete puts things. */
const isTrashedPath = (path: string): boolean =>
/^\/[^/]+\/Trash(\/|$)/u.test(path);
const maskedSelfPath = (entry: FSEntry, realPath: string): string => {
const owner = realPath.split('/')[1];
const name = realPath.split('/').pop();
return owner && name ? `/${owner}/${entry.uuid}/${name}` : realPath;
};
/** Where `entry` was, as this holder knew it; `root` carries the new path. */
const maskedFormerPath = (
root: FSEntry,
entry: FSEntry,
realPath: string,
): string | null =>
maskedPathVia(root, realPath) ??
(root.uuid === entry.uuid ? maskedSelfPath(entry, realPath) : null);
/** `realPath` as a holder of `root` addresses it; null when outside that share. */
const maskedPathVia = (root: FSEntry, realPath: string): string | null => {
const owner = root.path.split('/')[1];
if (!owner || !root.name) return null;
const base = `/${owner}/${root.uuid}/${root.name}`;
if (realPath === root.path) return base;
if (!realPath.startsWith(`${root.path}/`)) return null;
return base + realPath.slice(root.path.length);
};
// -- 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 {
/** Entries awaiting the next retire flush, deduped by uuid. */
#pendingRetire = new Map<string, FSEntry>();
/** The in-flight flush, shared by everything buffered for it. */
#retireFlush: Promise<void> | null = null;
/** New entries awaiting the next fan-out, by parent path. */
#pendingCreates = new Map<string, FSEntry[]>();
#createFlush: Promise<void> | null = null;
/**
* FS mutations only notify the owner, leaving a recipient's open window
* stale. Handled here rather than per controller so the audience logic
* lives in one place, and over the event bus because fs is constructed
* first and cannot depend on this service.
*/
override onServerStart(): void {
this.clients.event.on('fs.remove.node', (_key, data, meta) => {
if (fromAnotherNode(meta)) return;
const entry = (data as { node?: FSEntry })?.node;
if (!entry?.uuid) return;
// Returned so an `emitAndWait` caller can observe the cleanup; the
// FS path uses plain `emit`, where it stays best-effort.
return this.#scheduleRetire(entry).catch((err) => {
console.warn(
'[ShareService] failed to retire grants for a deleted entry:',
entry.uuid,
err,
);
});
});
this.clients.event.on('fs.move.node', (_key, data, meta) => {
if (fromAnotherNode(meta)) return;
const { node, fromPath, fromUserId } = (data ?? {}) as {
node?: FSEntry;
fromPath?: string;
fromUserId?: number;
};
if (!node?.uuid) return;
const notify = this.#fanOutMove(node, fromPath).catch(() => {
// A stale window is better than a failed move.
});
// Grants are keyed on uuid, so they would otherwise follow the
// entry into its new owner's tree — leaving the new owner with
// recipients they never agreed to. Awaited alongside the notify so
// an `emitAndWait` caller sees both.
if (typeof fromUserId !== 'number' || fromUserId === node.userId) {
return notify;
}
return Promise.all([
notify,
this.onEntryOwnerChanged(node).catch((err: unknown): void => {
console.warn(
'[ShareService] failed to retire grants after an ownership change:',
node.uuid,
err,
);
}),
]).then((): void => undefined);
});
// Owning a confirmed address is what turns it from a claim into an
// identity, and so the only moment an invite may become a grant. That
// happens on more paths than typing a code: an OIDC signup arrives with
// the provider's word for the address, and the change-email flow
// confirms the new one before it lands. Missing any of them strands the
// invite forever — there is no later event to catch.
const claimFor = (user_id?: number, email?: string) => {
if (!user_id || !email) return;
return this.claimPendingShares(user_id, email)
.then((claimed) =>
this.services.shareNotification.notifyClaimed(
user_id,
claimed,
),
)
.catch((err) => {
console.warn(
'[ShareService] failed to claim pending shares for',
user_id,
err,
);
});
};
this.clients.event.on('user.email-confirmed', (_key, data) => {
const { user_id, email } = (data ?? {}) as {
user_id?: number;
email?: string;
};
return claimFor(user_id, email);
});
this.clients.event.on('user.email-changed', (_key, data) => {
const { user_id, new_email } = (data ?? {}) as {
user_id?: number;
new_email?: string;
};
return claimFor(user_id, new_email);
});
this.clients.event.on('fs.write.file', (_key, data, meta) => {
if (fromAnotherNode(meta)) return;
const entry = (data as { node?: FSEntry })?.node;
if (!entry?.uuid) return;
return this.#fanOutToHolders(entry, 'outer.gui.item.updated').catch(
() => {
// Same — never fail a write over its notification.
},
);
});
// A create is `fs.create.<flavor>`, not `fs.write.file`.
this.clients.event.on('fs.create.*', (_key, data, meta) => {
if (fromAnotherNode(meta)) return;
const entry = (data as { node?: FSEntry })?.node;
if (!entry?.uuid) return;
return this.#scheduleCreateFanOut(entry).catch(() => {});
});
this.clients.event.on('fs.rename', (_key, data, meta) => {
if (fromAnotherNode(meta)) return;
const { node: entry, old_path: oldPath } = (data ?? {}) as {
node?: FSEntry;
old_path?: string;
};
if (!entry?.uuid) return;
return this.#fanOutToHolders(
entry,
'outer.gui.item.renamed',
(root) => {
// The GUI rewrites descendants and open windows by it.
const from = oldPath
? maskedFormerPath(root, entry, oldPath)
: null;
return from ? { old_path: from } : {};
},
).catch(() => {});
});
}
/**
* Buffer a removed entry for the next retire flush, and hand back the
* promise for the flush that will carry it.
*
* `remove()` emits one `fs.remove.node` per descendant in a synchronous
* loop, so deleting a directory arrives here as a burst. Retiring each one
* on its own would put one permission lookup per descendant on the delete
* path; coalescing turns a whole subtree into a handful of queries. The
* buffer is swapped out before the flush runs, so entries removed while it
* is in flight land in the next one rather than being lost.
*/
#scheduleRetire(entry: FSEntry): Promise<void> {
this.#pendingRetire.set(entry.uuid, entry);
this.#retireFlush ??= new Promise<void>((resolve, reject) => {
setImmediate(() => {
const batch = this.#pendingRetire;
this.#pendingRetire = new Map();
this.#retireFlush = null;
this.#flushRetire([...batch.values()]).then(resolve, reject);
});
});
return this.#retireFlush;
}
/** Same buffering for creates, by parent: siblings share one lookup. */
#scheduleCreateFanOut(entry: FSEntry): Promise<void> {
const parent = pathPosix.dirname(entry.path);
this.#pendingCreates.set(parent, [
...(this.#pendingCreates.get(parent) ?? []),
entry,
]);
this.#createFlush ??= new Promise<void>((resolve, reject) => {
setImmediate(() => {
const batch = this.#pendingCreates;
this.#pendingCreates = new Map();
this.#createFlush = null;
this.#flushCreates(batch).then(resolve, reject);
});
});
return this.#createFlush;
}
async #flushCreates(batch: Map<string, FSEntry[]>): Promise<void> {
for (const entries of batch.values()) {
const first = entries[0];
if (!first) continue;
// Safe from one sibling: nothing holds a share on an entry this new.
const groups = await this.#reachingRoots(first);
for (const { root, holders } of groups) {
for (const entry of entries) {
await this.#emitGui(
'outer.gui.item.added',
holders,
holderPayload(entry, root),
);
}
}
}
}
/**
* Retire the grants, then tell the recipients. The revoke reports exactly
* who lost access, which the index can no longer answer — its rows cascade
* away with the fsentry — and it reports it per permission, so the holders
* are attributed back to the entry each grant named.
*/
async #flushRetire(entries: FSEntry[]): Promise<void> {
const ownerOf = new Map(entries.map((e) => [e.uuid, e]));
const notified = new Map<string, Set<number>>();
for (let i = 0; i < entries.length; i += RETIRE_CHUNK_SIZE) {
const chunk = entries.slice(i, i + RETIRE_CHUNK_SIZE);
const removed = await this.onEntryDeleted(
chunk.map((entry) => entry.uuid),
);
const holdersByEntry = new Map<string, Set<number>>();
for (const row of removed) {
const uuid = uuidFromEntryPermission(row.permission);
const entry = uuid ? ownerOf.get(uuid) : undefined;
const holderId = Number(row.holder_user_id);
if (!entry || !Number.isFinite(holderId)) continue;
if (holderId === entry.userId) continue;
const holders =
holdersByEntry.get(entry.uuid) ?? new Set<number>();
holders.add(holderId);
holdersByEntry.set(entry.uuid, holders);
}
for (const [uuid, holders] of holdersByEntry) {
const entry = ownerOf.get(uuid) as FSEntry;
await this.#emitGui(
'outer.gui.item.removed',
[...holders],
holderPayload(entry),
);
notified.set(uuid, holders);
}
}
await this.#fanOutRetiredToAncestors(entries, notified);
}
/**
* Tell whoever reached these through a folder above them — their grant is
* on that folder, so the revoke reports no holder for them. Coalesced by
* parent like creates, so a subtree stays a few queries.
*/
async #fanOutRetiredToAncestors(
entries: FSEntry[],
notified: Map<string, Set<number>>,
): Promise<void> {
const byParent = new Map<string, FSEntry[]>();
for (const entry of entries) {
const parent = pathPosix.dirname(entry.path);
byParent.set(parent, [...(byParent.get(parent) ?? []), entry]);
}
for (const siblings of byParent.values()) {
const first = siblings[0];
if (!first) continue;
// Ancestors only; a share on the entry is the revoke's to report.
const groups = (await this.#reachingRoots(first)).filter(
({ root }) => root.uuid !== first.uuid,
);
for (const { root, holders } of groups) {
for (const entry of siblings) {
const unheard = holders.filter(
(holder) => !notified.get(entry.uuid)?.has(holder),
);
if (!unheard.length) continue;
await this.#emitGui(
'outer.gui.item.removed',
unheard,
holderPayload(entry, root),
);
}
}
}
}
/**
* A move seen from both ends, since it can take an item out of a share as
* easily as into it: both ends is a move, only the destination an arrival,
* only the origin a removal.
*/
async #fanOutMove(entry: FSEntry, fromPath?: string): Promise<void> {
const destination = await this.#reachingRoots(entry);
const origin =
fromPath && fromPath !== entry.path
? await this.#reachingRoots(entry, fromPath)
: destination;
const rootsBySide = (
groups: Array<{ root: FSEntry; holders: number[] }>,
) =>
new Map<number, FSEntry>(
groups.flatMap(({ root, holders }) =>
holders.map((holder) => [holder, root] as const),
),
);
const originRootOf = rootsBySide(origin);
const destinationRootOf = rootsBySide(destination);
// Keyed on both: a holder can see each end through a different share.
const batches = new Map<
string,
{
event: HolderGuiEvent;
from?: FSEntry;
to?: FSEntry;
holders: number[];
}
>();
const place = (
holder: number,
event: HolderGuiEvent,
from?: FSEntry,
to?: FSEntry,
) => {
const key = `${event}|${from?.id ?? ''}|${to?.id ?? ''}`;
const batch = batches.get(key) ?? { event, from, to, holders: [] };
batch.holders.push(holder);
batches.set(key, batch);
};
// A grant follows its entry into Trash, so Delete would read as a move
// and rename their item to the GUID Trash gave it. Listings omit it.
const nowTrashed = isTrashedPath(entry.path);
const wasTrashed = fromPath ? isTrashedPath(fromPath) : false;
for (const [holder, to] of destinationRootOf) {
if (nowTrashed) continue;
const from = originRootOf.get(holder);
place(
holder,
from && !wasTrashed
? 'outer.gui.item.moved'
: 'outer.gui.item.added',
from,
to,
);
}
for (const [holder, from] of originRootOf) {
if (nowTrashed) {
// Already gone from their view if it was trashed before.
if (!wasTrashed) place(holder, 'outer.gui.item.removed', from);
continue;
}
if (destinationRootOf.has(holder)) continue;
place(holder, 'outer.gui.item.removed', from);
}
for (const { event, from, to, holders } of batches.values()) {
if (event === 'outer.gui.item.removed') {
// Named by where they last saw it, which is all they have.
await this.#emitGui(
event,
holders,
holderPayload(entry, from as FSEntry, fromPath),
);
continue;
}
const payload = holderPayload(entry, to as FSEntry);
const formerPath =
from && fromPath
? maskedFormerPath(from, entry, fromPath)
: null;
// A share masks its own root, so this move is invisible to them.
if (formerPath && formerPath === payload.path) continue;
await this.#emitGui(event, holders, {
...payload,
// The GUI rewrites the item it already has by this.
...(formerPath ? { from_path: formerPath } : {}),
});
}
}
async #fanOutToHolders(
entry: FSEntry,
event: HolderGuiEvent,
extrasFor: (root: FSEntry) => Record<string, unknown> = () => ({}),
): Promise<void> {
// Ancestors too: someone given a folder sees what happens inside it,
// and the changed file itself carries no share of its own.
const groups = await this.#reachingRoots(entry);
// One event per root: the path depends on the share they came through.
for (const { root, holders } of groups) {
await this.#emitGui(event, holders, {
...holderPayload(entry, root),
...extrasFor(root),
});
}
}
/** Holders who can reach `entry`, grouped by the share they came through. */
async #reachingRoots(
entry: FSEntry,
/** Where to look from; the former path once the entry has moved. */
realPath: string = entry.path,
): Promise<Array<{ root: FSEntry; holders: number[] }>> {
const { rows, nodesById } = await this.#sharesReaching(entry, realPath);
// An index row outlives the grant it records, so a recipient revoked
// through the ACL alone would keep receiving pushes. Free when nothing
// is shared, which is the path every write takes.
const live = await this.#reachingHolders(rows, nodesById);
// Deepest root wins, so a holder with nested shares is told once.
const rootByHolder = new Map<number, FSEntry>();
for (const row of rows) {
const holderId = Number(row.holder_user_id);
const root = nodesById.get(Number(row.fsentry_id));
if (!root || !Number.isFinite(holderId)) continue;
if (holderId === entry.userId) continue;
if (!live.has(`${holderId}:${root.id}`)) continue;
const current = rootByHolder.get(holderId);
if (!current || root.path.length > current.path.length) {
rootByHolder.set(holderId, root);
}
}
const holdersByRoot = new Map<
number,
{ root: FSEntry; holders: number[] }
>();
for (const [holderId, root] of rootByHolder) {
const group = holdersByRoot.get(root.id) ?? { root, holders: [] };
group.holders.push(holderId);
holdersByRoot.set(root.id, group);
}
return [...holdersByRoot.values()];
}
/**
* Active shares on this node or on anything above it.
*
* This runs behind every file write, so it stays on indexed access paths
* only: the ancestors are resolved to row ids through the fsentry store
* (cached, and by path, which is indexed), and the share lookup is then a
* single `fsentry_id IN (...)` against `idx_share_fsentry`. Asking the
* share table to join fsentries and match `path IN (...)` instead put an
* un-indexable OR on the write path.
*/
async #sharesReaching(
entry: FSEntry,
realPath: string = entry.path,
): Promise<{
rows: ShareIndexRow[];
nodesById: Map<number, FSEntry>;
}> {
const ancestorPaths: string[] = [];
for (
let cursor = pathPosix.dirname(realPath);
cursor !== '/' && cursor !== '.';
cursor = pathPosix.dirname(cursor)
) {
ancestorPaths.push(cursor);
}
const ancestors =
ancestorPaths.length > 0
? await this.stores.fsEntry.getEntriesByPaths(ancestorPaths)
: new Map<string, FSEntry>();
const nodesById = new Map<number, FSEntry>(
[entry, ...ancestors.values()]
.filter((node) => typeof node.id === 'number')
.map((node) => [node.id, node]),
);
return {
rows: await this.stores.share.listReaching([...nodesById.keys()]),
nodesById,
};
}
async #emitGui(
event: HolderGuiEvent,
userIds: number[],
response: Record<string, unknown>,
): Promise<void> {
try {
await this.clients.event.emit(
event,
{ user_id_list: userIds, response },
{},
);
} catch {
// Non-critical.
}
}
// -- 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);
// Authorization before recipient resolution: a caller who cannot
// manage the entry must learn nothing from this endpoint — including
// whether an email or username has an account. "Recipient does not
// exist" may only be observed by someone entitled to share.
const entry = await this.#resolveEntry(input, actor);
await this.#assertCanManage(actor, entry, mode);
const resolved = await this.#resolveRecipient(input.recipient);
if (resolved.kind === 'pending') {
return this.#invite(actor, issuerId, entry, resolved.email, mode);
}
const holder = resolved.user;
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',
});
}
await this.#assertNotBlocked(issuerId, holder);
// 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 indexed = existing.some(
(row: { holder_user_id: number; issuer_user_id: number }) =>
row.holder_user_id === holder.id &&
row.issuer_user_id === issuerId,
);
// A grant can predate the index, so the index alone can't say whether
// this recipient already had reach here.
const hadAccess =
indexed || (await this.#hasGrantFrom(entry, holder.id, issuerId));
const releaseQuota = hadAccess
? null
: await this.#reserveDailyQuota(issuerId);
try {
// The grant is user-to-user and belongs to the user, so an app
// issues it on their behalf rather than in its own name. Which app
// asked is recorded on the index row below.
await this.services.acl.setUserUser(
userRelatedActor(actor),
this.#actorFor(holder),
this.#descriptorFor(entry),
mode,
);
const row = await this.stores.share.upsertActive({
issuerUserId: issuerId,
holderUserId: holder.id,
fsentryId: entry.id,
mode,
recipientEmail: holder.email ?? null,
issuerAppUid: this.#actingAppUid(actor),
});
return {
...this.#resolve(row, entry, actor, holder),
holderId: holder.id,
isNew: !hadAccess,
};
} catch (err) {
await releaseQuota?.();
// Undo only reach this call created. Rolling back a mode change
// would revoke access the caller already had and leave the index
// row pointing at a grant that no longer exists.
if (!hadAccess) {
await this.#revokeQuietly(
userRelatedActor(actor),
entry,
holder.username,
issuerId,
);
}
throw err;
}
}
/** Whether `issuerId` already grants `holderId` anything on this node. */
async #hasGrantFrom(
entry: FSEntry,
holderId: number,
issuerId: number,
): Promise<boolean> {
const rows = await this.stores.permission.readLinkedUserUserPerms(
holderId,
entryPermissions(entry.uuid),
);
return rows.some((row) => Number(row.issuer_user_id) === issuerId);
}
/** Everyone currently granting `holderId` something on this node. */
async #issuersGranting(
entry: FSEntry,
holderId: number,
): Promise<number[]> {
const rows = await this.stores.permission.readLinkedUserUserPerms(
holderId,
entryPermissions(entry.uuid),
);
return [
...new Set(rows.map((row) => Number(row.issuer_user_id))),
].filter((id) => Number.isFinite(id));
}
/**
* What still backs each (holder, entry) pair: the issuers with a live
* grant, whether a live grant exists that names no issuer (legacy flat
* entries), and whether the holder now owns the entry outright (a move
* handed the tree over — no grant row, held all the same).
*
* The index is not proof of access: a grant can be withdrawn or downgraded
* by a path that never touches a share row (an ACL mode change, say), and
* the listings publish name, size and a signed thumbnail URL — so rows are
* checked against the grants themselves.
*
* Two batched reads for all pairs together, however many holders: the
* linked rows by `holder IN … AND permission IN …`, the flat view as one
* multi-get. Reading per holder here turned a full outbound page into
* hundreds of queries on a cold cache.
*/
async #grantEvidence(
pairs: Array<{ holderId: number; entry: FSEntry }>,
): Promise<Map<string, GrantEvidence>> {
const evidence = new Map<string, GrantEvidence>();
const unique = new Map<string, { holderId: number; entry: FSEntry }>();
for (const pair of pairs) {
unique.set(`${pair.holderId}:${pair.entry.id}`, pair);
}
if (unique.size === 0) return evidence;
const refs = [...unique.values()].flatMap(({ holderId, entry }) =>
entryPermissions(entry.uuid).map((permission) => ({
holderUserId: holderId,
permission,
})),
);
const [linked, flat] = await Promise.all([
this.stores.permission.readLinkedUserUserPermsForHolders(
refs.map((ref) => ref.holderUserId),
refs.map((ref) => ref.permission),
),
this.stores.permission.getFlatUserPermsForRefs(refs),
]);
// Both reads folded onto (holder, permission); the linked read spans
// every holder's permissions, so it can return pairs never asked for —
// they simply go unread below.
const byHolderPerm = new Map<
string,
{ issuers: Set<number>; unattributed: boolean }
>();
const record = (
holderId: number,
permission: string,
issuer: unknown,
) => {
const key = `${holderId}:${permission}`;
const found = byHolderPerm.get(key) ?? {
issuers: new Set<number>(),
unattributed: false,
};
const issuerId = Number(issuer);
if (Number.isFinite(issuerId)) found.issuers.add(issuerId);
else found.unattributed = true;
byHolderPerm.set(key, found);
};
for (const row of linked) {
record(
Number(row.holder_user_id),
row.permission,
row.issuer_user_id,
);
}
for (const { ref, value } of flat) {
if (value.deleted) continue;
record(ref.holderUserId, ref.permission, value.issuer_user_id);
}
for (const [key, { holderId, entry }] of unique) {
const merged: GrantEvidence = {
issuers: new Set(),
unattributed: false,
owned: entry.userId === holderId,
};
for (const permission of entryPermissions(entry.uuid)) {
const found = byHolderPerm.get(`${holderId}:${permission}`);
if (!found) continue;
for (const issuer of found.issuers) merged.issuers.add(issuer);
merged.unattributed ||= found.unattributed;
}
evidence.set(key, merged);
}
return evidence;
}
/** Of `entries`, the uuids `holderId` still holds a live grant on. */
async #liveGrants(
holderId: number,
entries: FSEntry[],
): Promise<Set<string>> {
const evidence = await this.#grantEvidence(
entries.map((entry) => ({ holderId, entry })),
);
const live = new Set<string>();
for (const entry of entries) {
const found = evidence.get(`${holderId}:${entry.id}`);
if (!found) continue;
if (found.owned || found.unattributed || found.issuers.size > 0) {
live.add(entry.uuid);
}
}
return live;
}
/** The (holder, node) pairs `rows` name that both sides resolve for. */
#rowPairs(
rows: Array<{ holder_user_id: number | null; fsentry_id: number }>,
nodeById: Map<number, FSEntry>,
): Array<{ holderId: number; entry: FSEntry }> {
const pairs: Array<{ holderId: number; entry: FSEntry }> = [];
for (const row of rows) {
// `Number(null)` is 0, so a pending row must not slip through as
// holder 0.
if (!row.holder_user_id) continue;
const holderId = Number(row.holder_user_id);
const node = nodeById.get(Number(row.fsentry_id));
if (!node || !Number.isFinite(holderId)) continue;
pairs.push({ holderId, entry: node });
}
return pairs;
}
/**
* The `<holderId>:<fsentryId>` pairs some grant still backs, whoever issued
* it. The right bound for fan-out: a holder is reachable through anyone's
* grant.
*/
async #reachingHolders(
rows: ShareIndexRow[],
nodeById: Map<number, FSEntry>,
): Promise<Set<string>> {
const evidence = await this.#grantEvidence(
this.#rowPairs(rows, nodeById),
);
const live = new Set<string>();
for (const [key, found] of evidence) {
if (found.owned || found.unattributed || found.issuers.size > 0) {
live.add(key);
}
}
return live;
}
/**
* The `<holderId>:<fsentryId>:<issuerId>` triples whose own grant still
* stands. Sharper than `#reachingHolders`, and what the listings need: on
* the pair alone, an issuer whose grant was withdrawn outside `unshare` is
* shown a dead share for as long as anyone else still grants the same
* holder the same node. A grant that names no issuer backs every issuer's
* row for its pair, but only while no attributable grant exists — once any
* does, the attributed set is the answer.
*/
async #reachingGrants(
rows: Array<{
holder_user_id: number | null;
issuer_user_id: number;
fsentry_id: number;
}>,
nodeById: Map<number, FSEntry>,
): Promise<Set<string>> {
const evidence = await this.#grantEvidence(
this.#rowPairs(rows, nodeById),
);
const live = new Set<string>();
for (const row of rows) {
const holderId = Number(row.holder_user_id);
const node = nodeById.get(Number(row.fsentry_id));
if (!node || !Number.isFinite(holderId)) continue;
const found = evidence.get(`${holderId}:${node.id}`);
if (!found) continue;
const issuerId = Number(row.issuer_user_id);
const backed =
found.owned ||
found.issuers.has(issuerId) ||
(found.unattributed && found.issuers.size === 0);
if (backed) live.add(`${holderId}:${node.id}:${issuerId}`);
}
return live;
}
/**
* 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, resolved] = await Promise.all([
this.#resolveEntry(input, actor),
this.#resolveRecipient(input.recipient),
]);
// Nothing was granted, so there is only the invitation to take back.
if (resolved.kind === 'pending') {
await this.#assertCanManage(actor, entry);
return this.#cancelInvite(entry, resolved.email, issuerId);
}
const holder = resolved.user;
// 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 live grants when no index row exists — a grant may
// predate the index, and a revoke must still work. Reading them is what
// makes "leave this share" work at all: the issuer there is whoever
// shared with you, never yourself, so defaulting to the caller would
// scope the delete to a row that cannot exist and report nothing
// revoked. Indexed on holder_user_id, and only reached when the index
// came up empty.
let issuers: number[] =
rows.length > 0
? [
...new Set(
rows.map(
(row: { issuer_user_id: number }) =>
row.issuer_user_id,
),
),
]
: await this.#issuersGranting(entry, holder.id);
if (!isOwner && !isLeaving) {
issuers = issuers.filter((issuer) => issuer === issuerId);
}
if (issuers.length === 0) issuers = [issuerId];
return this.#withdrawGrants(actor, entry, holder, issuers);
}
/**
* Clear `issuers`' grants to `holder` on this node, and everything the
* holder re-shared in turn. The caller has already decided which issuers
* are in scope — everyone's for an owner, one row's for the uid-addressed
* revoke — and passed the gates for it.
*/
async #withdrawGrants(
actor: Actor,
entry: FSEntry,
holder: { id: number; username: string },
issuers: number[],
): Promise<{ revoked: number }> {
// Whatever the holder re-shared goes with them, and this has to run
// first: when the holder is the actor, clearing their own grants would
// strip the very `manage` the cascade needs to do it.
const writer = userRelatedActor(actor);
let revoked = await this.#revokeDownstream(writer, entry, holder.id);
for (const issuer of issuers) {
const { revoked: didRevoke, authorized } = await this.#revokeFor(
writer,
entry,
holder.username,
issuer,
);
if (didRevoke) revoked++;
if (authorized) {
await this.stores.share.deleteActive({
holderUserId: holder.id,
fsentryId: entry.id,
issuerUserId: issuer,
});
}
}
return { revoked };
}
/**
* Withdraw everything `issuerId` granted on this node, and everything those
* recipients granted in turn.
*
* `seen` guards the walk: two delegates can each have granted the other,
* and without it the recursion would not terminate.
*/
async #revokeDownstream(
actor: Actor,
entry: FSEntry,
issuerId: number,
seen: Set<number> = new Set(),
): Promise<number> {
if (seen.has(issuerId)) return 0;
seen.add(issuerId);
// Their unclaimed invites go the same way as their re-shares: an
// invite rests on the same authority, and nothing else retires it —
// claiming re-checks, but only when the recipient shows up, and until
// then the row keeps the entry in the revoked issuer's listing.
await this.stores.share.deletePendingByIssuerSubtree(
issuerId,
entry.id,
);
// The whole subtree, not just this node: `manage` inherits downwards,
// so a grant on a descendant can rest on authority held here.
const rows = (
await this.stores.share.listByFsentrySubtree(entry.id)
).filter(
(row: { issuer_user_id: number }) =>
Number(row.issuer_user_id) === issuerId,
);
if (rows.length === 0) return 0;
const [nodes, holders] = await Promise.all([
this.stores.fsEntry.getEntriesByIds(
rows.map((row: { fsentry_id: number }) =>
Number(row.fsentry_id),
),
),
this.stores.user.getByIds(
rows.map((row: { holder_user_id: number }) =>
Number(row.holder_user_id),
),
),
]);
let revoked = 0;
for (const row of rows) {
const holderId = Number(row.holder_user_id);
const node = nodes.get(Number(row.fsentry_id));
const downstream = holders.get(holderId);
if (!node || !downstream?.username) continue;
const { revoked: didRevoke, authorized } = await this.#revokeFor(
actor,
node,
downstream.username,
issuerId,
);
if (didRevoke) revoked++;
if (authorized) {
await this.stores.share.deleteActive({
holderUserId: holderId,
fsentryId: node.id,
issuerUserId: issuerId,
});
}
// Only carry on down if this actually cost them their authority.
// A delegate granted `manage` by two people keeps it when one
// withdraws, and what they granted is not theirs to lose.
const stillHolds =
await this.services.permission.canManagePermission(
this.#actorFor(downstream),
`fs:${node.uuid}:read`,
);
if (stillHolds) continue;
revoked += await this.#revokeDownstream(
actor,
entry,
holderId,
seen,
);
}
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
* cascade away with the fsentry.
*/
async onEntryDeleted(entryUids: string | string[]): Promise<
Array<{
holder_user_id: number;
issuer_user_id: number;
permission: string;
}>
> {
const uids = Array.isArray(entryUids) ? entryUids : [entryUids];
if (uids.length === 0) return [];
// Two prefixes per entry, because `manage:fs:<uuid>` does not sit under
// `fs:<uuid>` — leaving it behind would keep a live grant on a node
// that no longer exists, and `manage` answers every mode.
const removed =
await this.stores.permission.deleteUserUserPermsByPermissionPrefixes(
uids.flatMap((uid) => [`fs:${uid}`, `manage:fs:${uid}`]),
);
// Retiring the rows is not enough: a holder's cached scan still answers
// "allowed" until it ages out. The revoke path bumps for this reason,
// and a delete has to as well or access outlives the file.
const holderIds = [
...new Set(removed.map((row) => Number(row.holder_user_id))),
].filter((id) => Number.isFinite(id));
const holders = await this.stores.user.getByIds(holderIds);
await this.stores.permission.bumpCacheGenerations(
[...holders.values()]
.filter((user) => user.uuid)
.map((user) => `user:${user.uuid}`),
);
return removed;
}
/**
* Drop the index row behind a grant that was withdrawn through the
* permission API rather than `unshare`. The row would otherwise outlive the
* access it records, and a listing reads the index, not the grant.
*/
async onGrantRevoked(
issuer: Actor,
holderUsername: string,
permission: string,
): Promise<void> {
const uuid = uuidFromEntryPermission(permission);
if (!uuid) return;
const issuerId = issuer?.user?.id;
const [entry, holder] = await Promise.all([
this.stores.fsEntry.getEntryByUuid(uuid),
this.stores.user.getByUsername(holderUsername),
]);
if (!entry || !holder || typeof issuerId !== 'number') return;
await this.stores.share.deleteActive({
holderUserId: holder.id,
fsentryId: entry.id,
issuerUserId: issuerId,
});
}
/**
* Retire the shares on a node that has just changed owner.
*
* A grant names a uuid, so it would otherwise ride along into the new
* owner's tree and leave them with recipients they never agreed to. The
* fsentry survives the move, so nothing cascades — the rows have to go
* explicitly.
*
* Scoped by the share index rather than by walking the subtree: the work is
* bounded by how many shares exist under the node (usually none), not by
* how many files it holds, and the recursive walk rides `parent_id`.
*/
async onEntryOwnerChanged(entry: FSEntry): Promise<void> {
const rows = entry.isDir
? await this.stores.share.listByFsentrySubtree(entry.id)
: await this.stores.share.listByFsentry(entry.id);
const fsentryIds = [
...new Set([
entry.id,
...rows.map((row: { fsentry_id: number }) =>
Number(row.fsentry_id),
),
]),
].filter((id) => Number.isFinite(id));
const nodes = await this.stores.fsEntry.getEntriesByIds(fsentryIds);
const uuids = [...nodes.values()].map((node) => node.uuid);
if (uuids.length > 0) await this.onEntryDeleted(uuids);
await this.stores.share.deleteByFsentryIds(fsentryIds);
}
// -- Reads --------------------------------------------------------
/**
* What has been shared with `actor`, newest page first by id. Entries are
* hydrated in one batch; rows whose entry is gone, or which resolve into
* the owner's trash, are dropped — the share survives a trashing so a
* restore is lossless, it just shouldn't be listed.
*/
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),
),
...[...entries.values()].map((entry) => entry.userId),
]);
// Everything listed here is a shared root, so record them all: entries
// reached by opening one keep the same masked root and stay navigable.
await learnShareRoots([...entries.values()], actor);
// Two independent bounds. The grant has to still be there — the index
// row outlives it if access was withdrawn any other way. And an app
// sees only the part of that its user handed to the app, where a
// session sees all of it.
const [live, reachable] = await Promise.all([
this.#liveGrants(holderId, [...entries.values()]),
this.#reachableBy(actor, [...entries.values()]),
]);
const items: ResolvedShare[] = [];
for (const row of page.items) {
const entry = entries.get(Number(row.fsentry_id));
if (!entry || this.#isTrashed(entry)) continue;
if (!live.has(entry.uuid)) continue;
if (!reachable.has(entry.uuid)) continue;
items.push(
this.#resolvedShareRow(row, entry, issuers, {
entryMeta: true,
// Which app the sharer went through is their business, not
// the recipient's.
provenance: false,
holderUsername: actor.user.username ?? null,
}),
);
}
return {
items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(opts.includeTotal
? { total: await this.stores.share.countByHolder(holderId) }
: {}),
};
}
/**
* What the caller has shared out, across every item: the shares they
* issued, plus the ones a `manage` delegate issued on a node they own.
* `listSharesOf` answers the same question for a node the caller can
* already name; this is what answers it when they can't.
*
* Trashed items are kept, unlike the inbound listing: the grant on one is
* still standing, and this is where someone comes to find that out.
*
* An app credential sees only what its own app issued, whatever `appUid`
* asks for; a session may filter by app, and `null` asks for the grants no
* app issued.
*/
async listSharedByMe(
actor: Actor,
opts: {
limit?: number;
cursor?: string;
includeTotal?: boolean;
appUid?: string | null;
} = {},
): Promise<{
items: ResolvedShare[];
cursor?: string;
total?: number;
}> {
const userId = this.#requireUserId(actor);
const scope = this.#outboundAppScope(actor, opts.appUid);
if (scope.empty) {
return {
items: [],
...(opts.includeTotal ? { total: 0 } : {}),
};
}
const page = await this.stores.share.listOutbound(userId, {
limit: opts.limit,
cursor: opts.cursor,
appUid: scope.appUid,
});
const rows: OutboundShareRow[] = page.items;
const entries = await this.stores.fsEntry.getEntriesByIds(
rows.map((row) => Number(row.fsentry_id)),
);
const users = await this.stores.user.getByIds([
...rows.flatMap((row) => [
Number(row.issuer_user_id),
...(row.holder_user_id ? [Number(row.holder_user_id)] : []),
]),
...[...entries.values()].map((entry) => entry.userId),
]);
const nodeById = new Map<number, FSEntry>(
[...entries.values()].map((entry) => [entry.id, entry]),
);
// Three bounds. A claimed row needs the grant *this issuer* made to
// still be there — on the pair alone, a grant withdrawn outside
// `unshare` stays listed while anyone else grants the same holder the
// same node. An invite needs its issuer to still hold the authority it
// would grant, or a revoked delegate keeps reading the entry's name
// and size out of invites that can never be claimed. And an app sees
// only the part of any of it the credential reaches in its own right.
const [stillReaches, pendingAllowed, reachable] = await Promise.all([
this.#reachingGrants(rows, nodeById),
this.#pendingStillAuthorized(
rows.filter((row) => !row.holder_user_id),
nodeById,
users,
),
this.#reachableBy(actor, [...entries.values()]),
]);
const items: ResolvedShare[] = [];
for (const row of rows) {
const entry = entries.get(Number(row.fsentry_id));
if (!entry) continue;
if (!reachable.has(entry.uuid)) continue;
const pending = !row.holder_user_id;
if (pending && !pendingAllowed.has(row.uid)) continue;
if (
!pending &&
!stillReaches.has(
`${Number(row.holder_user_id)}:${entry.id}:${Number(row.issuer_user_id)}`,
)
) {
continue;
}
items.push(
this.#resolvedShareRow(row, entry, users, { entryMeta: true }),
);
}
return {
items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(opts.includeTotal
? {
total: await this.stores.share.countOutbound(userId, {
appUid: scope.appUid,
}),
}
: {}),
};
}
/**
* Which apps hold shares the caller made — the way into the per-app listing
* for someone who doesn't know which apps to ask about. The group with no
* `appUid` is what the caller shared themselves.
*
* Counts come from the index, so they are what `includeTotal` reports on
* the listing rather than what survives its per-grant re-checks.
*/
async listSharedByMeApps(
actor: Actor,
opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {},
): Promise<{
items: OutboundAppSummary[];
cursor?: string;
total?: number;
}> {
const userId = this.#requireUserId(actor);
if (this.#actingAppUid(actor)) {
throw new HttpError(
403,
'This view is only available to user sessions',
{ legacyCode: 'forbidden' },
);
}
const page = await this.stores.share.listOutboundApps(userId, {
limit: opts.limit,
cursor: opts.cursor,
});
const apps = await this.stores.app.getByUids(
page.items
.map((row: { appUid: string | null }) => row.appUid)
.filter((uid: string | null): uid is string => Boolean(uid)),
);
return {
items: page.items.map(
(row: { appUid: string | null; count: number }) => {
// An app that has since been removed leaves its grants behind,
// so the group stands with nothing to name it.
const app = row.appUid ? apps.get(row.appUid) : null;
return {
appUid: row.appUid,
name: app?.name ?? null,
title: app?.title ?? null,
count: row.count,
};
},
),
...(page.cursor ? { cursor: page.cursor } : {}),
...(opts.includeTotal
? { total: await this.stores.share.countOutboundApps(userId) }
: {}),
};
}
/**
* Withdraw one share the caller listed, named by its own uid.
*
* An uid the caller may not see answers 404 alike — one that names nothing,
* another user's row, an app's view of another app's row — so the endpoint
* can't be used to find out which. Past that gate the revoke's own rules
* answer: a caller whose authority over the node has lapsed gets the ACL's
* error, and a grant already withdrawn elsewhere reports `revoked: 0`.
*
* Scoped to the named row: only its issuer's grant is withdrawn, and only
* this one invite is cancelled — an owner (or an app) addressing one row
* must not take another issuer's grant on the same pair with it. The
* item-addressed `unshare` is the broad form.
*/
async revokeSharedByMe(
actor: Actor,
shareUid: string,
): Promise<{ revoked: number }> {
const userId = this.#requireUserId(actor);
const notFound = () =>
new HttpError(404, 'Subject does not exist', {
legacyCode: 'subject_does_not_exist',
});
const row = await this.stores.share.getByUid(shareUid);
if (!row?.fsentry_id) throw notFound();
const actingApp = this.#actingAppUid(actor);
if (actingApp && issuedByApp(row) !== actingApp) throw notFound();
const entry = await this.stores.fsEntry.getEntryById(
Number(row.fsentry_id),
);
if (!entry) throw notFound();
// The row must be one the caller's own listing would show them.
if (
Number(row.issuer_user_id) !== userId &&
Number(entry.userId) !== userId
) {
throw notFound();
}
if (!(await this.#hasOwnReach(actor, entry, 'see'))) throw notFound();
// An invite is just its row — including one whose address has since
// been registered but never claimed: no holder, no grant, so
// recipient-addressed revocation would never find it.
if (!row.holder_user_id) {
const removed = await this.stores.share.deleteByUid(row.uid);
return { revoked: removed ? 1 : 0 };
}
const holder = await this.stores.user.getById(
Number(row.holder_user_id),
);
if (!holder?.username) throw notFound();
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',
});
}
return this.#withdrawGrants(actor, entry, holder, [
Number(row.issuer_user_id),
]);
}
/**
* When a grant was made, by which actor, and under which app.
*
* Named with an item, this is everything granted on it, whoever granted it
* — which is what an owner is left with after a revoke, the grant itself
* being gone by then. Named with nothing, it is what the caller granted,
* wherever it landed. Between them they cover the caller's own trail and
* their items', and nothing else: authority over the item is the gate on
* the first, and being the issuer is the whole of the second.
*/
async listGrantAudit(
actor: Actor,
target: ShareTarget = {},
opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {},
): Promise<{
items: GrantAuditEntry[];
cursor?: string;
total?: number;
}> {
const userId = this.#requireUserId(actor);
let filter: UserUserAuditFilter;
if (target.uid || target.path) {
const entry = await this.#resolveEntry(target, actor);
await this.#assertCanManage(actor, entry);
filter = { permissions: entryPermissions(entry.uuid) };
} else {
filter = { issuerUserId: userId };
}
const page = await this.stores.permission.listUserUserAudit(filter, {
limit: opts.limit,
cursor: opts.cursor,
});
const users = await this.stores.user.getByIds(
page.items.flatMap((row) =>
[row.issuer_user_id, row.holder_user_id].filter(
(id): id is number => typeof id === 'number',
),
),
);
const username = (id: number | null) =>
id === null ? null : (users.get(id)?.username ?? null);
return {
items: page.items.map((row) => ({
action: row.action,
permission: row.permission,
entryUid: uuidFromEntryPermission(row.permission),
mode: modeFromEntryPermission(row.permission),
issuer: { username: username(row.issuer_user_id) },
holder: { username: username(row.holder_user_id) },
appUid:
typeof row.extra?.appUid === 'string'
? row.extra.appUid
: null,
createdAt: row.created_at,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(opts.includeTotal
? {
total: await this.stores.permission.countUserUserAudit(
filter,
),
}
: {}),
};
}
/**
* Which app's grants this actor may see. An app credential is bound to its
* own app whatever it asks for; a session may filter freely.
*/
#outboundAppScope(
actor: Actor,
requested: string | null | undefined,
): { appUid?: string | null; empty?: boolean } {
const acting = this.#actingAppUid(actor);
if (!acting) return { appUid: requested };
if (requested !== undefined && requested !== acting) {
return { empty: true };
}
return { appUid: acting };
}
/**
* The app this credential acts as, or null for a plain user session.
* `effectiveApp` is the one derived field for this question — see
* `makeActor`; re-deriving from `app` here would be a second gate to
* drift.
*/
#actingAppUid(actor: Actor): string | null {
return actor.effectiveApp?.uid ?? null;
}
/**
* 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, actor);
await this.#assertCanManage(actor, entry);
// Access is inherited down the tree, so a node's own rows are only
// half the answer — without the ancestors' the caller is told nobody
// can reach a file that several people can.
const ancestors = (
await this.services.fs.getAncestorChain(entry.path)
).slice(1);
const ancestorNodes = await this.stores.fsEntry.getEntriesByPaths(
ancestors.map((ancestor) => ancestor.path),
);
// The ancestor a share was granted on is published masked too: to a
// delegate, the folder above their share is still the owner's business.
const viaById = new Map(
[...ancestorNodes.values()].map((node) => [
node.id,
maskEntryPath(node),
]),
);
const nodeById = new Map(
[entry, ...ancestorNodes.values()].map((node) => [node.id, node]),
);
const inherited: Array<{ row: ShareIndexRow; via: string }> = (
await this.stores.share.listByFsentries([...viaById.keys()])
).map((row: ShareIndexRow) => ({
row,
via: viaById.get(Number(row.fsentry_id)) as string,
}));
const rows = await this.stores.share.listByFsentry(entry.id);
const pendingRows = await this.stores.share.listPendingOnFsentry(
entry.id,
);
const userIds = [
...[...rows, ...inherited.map((i) => i.row)].flatMap(
(row: { issuer_user_id: number; holder_user_id: number }) => [
Number(row.issuer_user_id),
Number(row.holder_user_id),
],
),
...pendingRows.map((row: { issuer_user_id: number }) =>
Number(row.issuer_user_id),
),
];
const users = await this.stores.user.getByIds(userIds);
const maskedPath = maskEntryPath(entry);
// As in `#liveGrants`: an index row outlives the grant it records —
// and it names an issuer, so it is that issuer's grant that has to
// still be there.
const stillReaches = await this.#reachingGrants(
[...rows, ...inherited.map((i) => i.row)],
nodeById,
);
const isLive = (row: ShareIndexRow): boolean =>
stillReaches.has(
`${Number(row.holder_user_id)}:${Number(row.fsentry_id)}:${Number(row.issuer_user_id)}`,
);
// Every item reports the queried node — a grant on an ancestor is
// still published against the path the caller asked about.
const inheritedShares: ResolvedShare[] = inherited
.filter(({ row }) => isLive(row))
.map(({ row, via }) =>
this.#resolvedShareRow(row, entry, users, {
path: maskedPath,
via,
}),
);
const own: ResolvedShare[] = rows
.filter(isLive)
.map((row: OutboundShareRow) =>
this.#resolvedShareRow(row, entry, users, {
path: maskedPath,
}),
);
// Nobody holds an invite yet, but whoever manages the node needs to
// see who was asked, and be able to take it back.
const pending: ResolvedShare[] = pendingRows.map(
(row: OutboundShareRow) =>
this.#resolvedShareRow(row, entry, users, {
path: maskedPath,
}),
);
return inheritedShares.concat(own, pending);
}
/** Whether each of the caller's own `entries` is shared, keyed by uuid. */
async shareFlags(
actor: Actor,
entries: FSEntry[],
): Promise<Map<string, boolean>> {
// No user behind the actor means no flag, not a failed listing.
const userId = actor?.user?.id;
if (typeof userId !== 'number') return new Map();
const own = entries.filter(
(entry) => entry.userId === userId && Number.isFinite(entry.id),
);
if (own.length === 0) return new Map();
const sharedIds = await this.stores.share.getSharedFsentryIds(
own.map((entry) => entry.id),
);
return new Map(
own.map((entry) => [entry.uuid, sharedIds.has(entry.id)]),
);
}
/** `listSharesOf`, but null instead of throwing when manage is missing. */
async tryListSharesOf(
actor: Actor,
target: ShareTarget,
): Promise<ResolvedShare[] | null> {
try {
return await this.listSharesOf(actor, target);
} catch (error) {
if (
isHttpError(error) &&
(error.statusCode === 403 || error.statusCode === 404)
) {
return null;
}
throw error;
}
}
// -- Blocking -----------------------------------------------------
/**
* Refuse shares from everyone, or accept them again. The blanket answer to
* the same question `blockSender` answers about one person; the per-sender
* list is kept either way, so turning this off restores it rather than
* 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.
*/
async setBlockAllSenders(
actor: Actor,
blocked: boolean,
): Promise<{ all: boolean }> {
const blockerId = this.#requireUserId(actor);
await this.stores.user.updateMetadata(blockerId, {
[BLOCK_ALL_SHARES_KEY]: blocked,
});
return { all: blocked };
}
/**
* 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.
*/
async blockSender(
actor: Actor,
username: string,
): Promise<{ username: string; created: boolean }> {
const blockerId = this.#requireUserId(actor);
const target = await this.#requireUserByUsername(username);
if (target.id === blockerId) {
throw new HttpError(400, 'cannot block yourself', {
legacyCode: 'cannot_block_self',
});
}
const created = await this.stores.userBlock.create(
blockerId,
target.id,
);
return { username: target.username as string, created };
}
/** Accept shares from `username` again. */
async unblockSender(
actor: Actor,
username: string,
): Promise<{ username: string; unblocked: boolean }> {
const blockerId = this.#requireUserId(actor);
const target = await this.#requireUserByUsername(username);
const unblocked = await this.stores.userBlock.deleteByPair(
blockerId,
target.id,
);
return { username: target.username as string, unblocked };
}
/**
* Who the caller refuses shares from, and whether they refuse everyone.
* Usernames only — ids aren't theirs.
*/
async listBlockedSenders(actor: Actor): Promise<{
all: boolean;
items: Array<{ username: string; createdAt: number }>;
}> {
const blockerId = this.#requireUserId(actor);
const [blocker, rows] = await Promise.all([
this.stores.user.getById(blockerId),
this.stores.userBlock.listByBlocker(blockerId),
]);
const users = await this.stores.user.getByIds(
rows.map((row) => Number(row.blocked_user_id)),
);
const items: Array<{ username: string; createdAt: number }> = [];
for (const row of rows) {
// A miss means the read raced an account deletion.
const username = users.get(Number(row.blocked_user_id))?.username;
if (!username) continue;
items.push({ username, createdAt: Number(row.created_at) });
}
return { all: blocksAllShares(blocker), items };
}
/**
* Stop here when the recipient is not accepting this share. Said plainly
* rather than disguised as a missing recipient, so a sender whose share
* will never arrive stops re-sending it; only a caller who already passed
* the manage check can get this far, so it is no probe for who blocked
* whom.
*
* Refusing everyone and refusing this sender report identically — which of
* the two it is is the recipient's business, not the sender's.
*
* The recipient's row is already in hand from resolution, so the blanket
* switch is free; only a caller who cleared it pays for the pair lookup.
*/
async #assertNotBlocked(issuer: number, holder: UserRow): Promise<void> {
const blocked =
blocksAllShares(holder) ||
(await this.stores.userBlock.isBlocked(holder.id, issuer));
if (!blocked) return;
throw new HttpError(403, 'recipient is not accepting shares', {
legacyCode: 'recipient_not_accepting_shares',
});
}
// -- Internals ----------------------------------------------------
async #requireUserByUsername(username: string): Promise<UserRow> {
const name = typeof username === 'string' ? username.trim() : '';
const user = name ? await this.stores.user.getByUsername(name) : null;
if (!user?.username) {
throw new HttpError(404, 'Recipient does not exist', {
legacyCode: 'user_does_not_exist',
});
}
return user;
}
/**
* Turn every invite aimed at `email` into a real grant, now that its owner
* is known.
*
* Each is re-authorized as it is claimed: an invite can sit for weeks, and
* the issuer may have lost the right to share it since. One that no longer
* holds is dropped, and never blocks the rest.
*/
async claimPendingShares(
holderUserId: number,
email: string,
): Promise<ResolvedShare[]> {
// Canonical on both sides: rows are stored cleaned, and the confirmed
// address may be any variant of what the sharer typed.
const pending = await this.stores.share.listPendingByEmail(
cleanEmail(email),
);
if (pending.length === 0) return [];
const holder = await this.stores.user.getById(holderUserId);
if (!holder?.username) return [];
const claimed: ResolvedShare[] = [];
for (const row of pending) {
try {
const entry = await this.stores.fsEntry.getEntryById(
Number(row.fsentry_id),
);
if (!entry) {
await this.stores.share.deleteByUid(row.uid);
continue;
}
// The address may have been theirs all along.
if (
entry.userId === holderUserId ||
Number(row.issuer_user_id) === holderUserId
) {
await this.stores.share.deleteByUid(row.uid);
continue;
}
const issuer = await this.stores.user.getById(
Number(row.issuer_user_id),
);
if (!issuer) {
await this.stores.share.deleteByUid(row.uid);
continue;
}
// An invite can sit for weeks; its address's owner may have
// stopped accepting shares — from this sender, or from anyone
// — since it was sent.
if (
blocksAllShares(holder) ||
(await this.stores.userBlock.isBlocked(
holderUserId,
Number(row.issuer_user_id),
))
) {
await this.stores.share.deleteByUid(row.uid);
continue;
}
const issuerActor = this.#actorFor(issuer);
// Re-authorized against the mode the invite actually grants:
// an issuer can keep authority over `read` while having lost
// `write`, and checking a fixed `read` here would wave a
// write-mode invite through to a grant that then fails.
const stillAllowed =
await this.services.permission.canManagePermission(
issuerActor,
entryPermissionForMode(entry.uuid, row.mode as string),
);
if (!stillAllowed) {
await this.stores.share.deleteByUid(row.uid);
continue;
}
// The row is claimed before the grant is written. In this
// order, losing the race to a concurrent cancel means no grant
// exists yet — nothing to clean up. Granting first left a
// durable permission behind whenever the cancel won, and no
// listing showed it, because listings are driven by the rows.
let applied;
try {
applied = await this.stores.share.applyPending({
uid: row.uid,
holderUserId,
});
} catch (err) {
// A duplicate of an invite already claimed (or of an
// active share) collides with the unique index the moment
// it gains a holder. It can never be applied, so it is
// noise to be cleared, not an invite to keep retrying.
if (!isUniqueViolation(err)) throw err;
await this.stores.share.deleteByUid(row.uid);
continue;
}
if (!applied) continue;
try {
await this.services.acl.setUserUser(
issuerActor,
this.#actorFor(holder),
this.#descriptorFor(entry),
row.mode as AclMode,
);
} catch (err) {
// The row now names a holder but no grant backs it; left
// standing it would re-fail identically on every future
// claim. An invite whose grant cannot be written no longer
// holds, and those are dropped.
await this.stores.share.deleteByUid(row.uid);
throw err;
}
claimed.push({
...this.#resolve(applied, entry, issuerActor, holder),
holderId: holderUserId,
isNew: true,
});
} catch (err) {
console.warn(
'[ShareService] could not claim pending share',
row.uid,
err,
);
}
}
return claimed;
}
/**
* Withdraw an invite before it is claimed. An owner may clear any issuer's
* invite on their node; anyone else only the ones they sent.
*/
async #cancelInvite(
entry: FSEntry,
email: string,
issuerId: number,
): Promise<{ revoked: number }> {
const isOwner = entry.userId === issuerId;
const rows = (
await this.stores.share.listPendingByEmail(cleanEmail(email))
).filter(
(row: { fsentry_id: number; issuer_user_id: number }) =>
Number(row.fsentry_id) === entry.id &&
(isOwner || Number(row.issuer_user_id) === issuerId),
);
let revoked = 0;
for (const row of rows) {
if (await this.stores.share.deleteByUid(row.uid)) revoked += 1;
}
return { revoked };
}
/**
* Record a share for an address with no confirmed account. There is nobody
* to grant to, so the row is the whole share until it is claimed.
*
* Spends daily quota: an invite is reach the issuer is handing out, and
* exempting it would make the limit optional.
*/
async #invite(
actor: Actor,
issuerId: number,
entry: FSEntry,
email: string,
mode: AclMode,
): Promise<ResolvedShare> {
// Checked before anything is written or spent: an address that can't
// receive the invite must not become a permanent pending row. The
// send-time check can't do this — by then the row exists whatever
// happens to the email.
if (
!EMAIL_SHAPE.test(email) ||
!(await this.clients.email.validate(email))
) {
throw new HttpError(400, 'invalid recipient email address', {
legacyCode: 'email_not_allowed',
});
}
// An alias we won't grant on can still land in the blocker's inbox.
const mayReach =
(await this.stores.user.findEmailOwner(email)) ??
(await this.stores.user.getByCleanEmail(abuseKey(email)));
if (mayReach) await this.#assertNotBlocked(issuerId, mayReach);
// Stored canonicalized, because claiming matches on it: the confirmed
// address arrives in whatever form the signup normalized to, and an
// exact match against what the sharer happened to type loses the
// invite to a capital letter. The typed form still matters — it is
// where the invite email goes, and what the sharer recognizes in the
// dialog — so it rides along in the row's data.
const canonical = cleanEmail(email);
const existing = await this.stores.share.listPendingByEmail(canonical);
const already = existing.some(
(row: { fsentry_id: number; issuer_user_id: number }) =>
Number(row.fsentry_id) === entry.id &&
Number(row.issuer_user_id) === issuerId,
);
const releaseQuota = already
? null
: await this.#reserveDailyQuota(issuerId);
try {
const { row, created } = await this.stores.share.upsertPending({
issuerUserId: issuerId,
recipientEmail: canonical,
displayEmail: email,
fsentryId: entry.id,
mode,
issuerAppUid: this.#actingAppUid(actor),
});
return {
...this.#resolve(row, entry, actor, { username: null }),
pending: true,
recipientEmail: email,
isNew: created,
};
} catch (err) {
await releaseQuota?.();
throw err;
}
}
/**
* One index row resolved for a listing. The listings' differences are
* arguments rather than a hand-built literal per call site: `entry` is the
* node the item reports (the row's own node in the flat listings, the
* queried node in `listSharesOf`), `entryMeta` adds what the caller can't
* stat for themselves, `provenance` withholds who-issued-how from a listing
* whose caller it isn't for, and `via` marks access inherited from an
* ancestor. A row with no holder is an unclaimed invite and reports the
* address it was aimed at — the typed form when that differs from the
* canonical one the row is keyed on, since that is what the sharer will
* recognize in a dialog.
*/
#resolvedShareRow(
row: OutboundShareRow,
entry: FSEntry,
users: Map<number, UserRow>,
opts: {
entryMeta?: boolean;
provenance?: boolean;
holderUsername?: string | null;
via?: string | null;
path?: string;
} = {},
): ResolvedShare {
const pending = !row.holder_user_id;
return {
uid: String(row.uid),
mode: String(row.mode),
path: opts.path ?? maskEntryPath(entry),
...(opts.entryMeta
? {
name: entry.name,
type: entry.isDir
? 'folder'
: contentTypeFromMime(entry.name) || null,
thumbnail: entry.thumbnail ?? null,
owner: {
username:
users.get(Number(entry.userId))?.username ?? null,
},
}
: {}),
entryUid: entry.uuid,
isDir: Boolean(entry.isDir),
issuer: {
username:
users.get(Number(row.issuer_user_id))?.username ?? null,
},
holder: {
username:
opts.holderUsername !== undefined
? opts.holderUsername
: pending
? null
: (users.get(Number(row.holder_user_id))?.username ??
null),
},
...(pending
? {
pending: true,
recipientEmail:
(row.data as { invitedAddress?: string } | null)
?.invitedAddress ?? row.recipient_email,
}
: {}),
createdAt: row.created_at,
...(opts.provenance === false
? {}
: {
issuedByApp: issuedByApp(row),
inheritedFrom: opts.via ?? null,
}),
modified: entry.modified,
size: entry.size,
};
}
/**
* Of `rows` (unclaimed invites), the uids whose issuer still holds the
* authority the invite would grant. A dead invite can never become access —
* claiming re-authorizes — but listed it would keep publishing the entry's
* name and size to an issuer whose own access was revoked.
*
* The owner's invites are theirs by definition; only a delegate's cost a
* check, and those are rare on any page.
*/
async #pendingStillAuthorized(
rows: OutboundShareRow[],
nodeById: Map<number, FSEntry>,
users: Map<number, UserRow>,
): Promise<Set<string>> {
const allowed = new Set<string>();
const toCheck: OutboundShareRow[] = [];
for (const row of rows) {
const entry = nodeById.get(Number(row.fsentry_id));
const issuer = users.get(Number(row.issuer_user_id));
if (!entry || !issuer?.username) continue;
if (entry.userId === issuer.id) allowed.add(row.uid);
else toCheck.push(row);
}
await runWithConcurrencyLimitSettled(toCheck, 8, async (row) => {
const entry = nodeById.get(Number(row.fsentry_id)) as FSEntry;
const issuer = users.get(Number(row.issuer_user_id)) as UserRow;
const ok = await this.services.permission.canManagePermission(
this.#actorFor(issuer),
entryPermissionForMode(entry.uuid, String(row.mode)),
);
if (ok) allowed.add(row.uid);
});
return allowed;
}
#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: maskEntryPath(entry),
name: entry.name,
entryUid: entry.uuid,
isDir: Boolean(entry.isDir),
issuer: { username: issuer.user.username ?? null },
holder: { username: holder.username ?? null },
createdAt: row.created_at,
modified: entry.modified,
size: entry.size,
};
}
/** A plain user actor, for asking the permission layer about someone else. */
#actorFor(user: { id: number; uuid?: string; username?: string }): Actor {
return {
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
} as Actor['user'],
effectiveApp: null,
};
}
#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, actor?: Actor): Promise<FSEntry> {
const entry = target.uid
? await this.stores.fsEntry.getEntryByUuid(target.uid)
: target.path
? await this.stores.fsEntry.getEntryByPath(
await resolveSharePath(
this.stores.fsEntry,
actor,
target.path,
),
)
: null;
if (!entry) {
throw new HttpError(404, 'Subject does not exist', {
legacyCode: 'subject_does_not_exist',
});
}
return entry;
}
/**
* Who the share is for. An unconfirmed address resolves to an invite rather
* than a failure; a username cannot be invited, there is nothing to reach.
*/
async #resolveRecipient(
recipient: ShareRecipient,
): Promise<
{ kind: 'user'; user: UserRow } | { kind: 'pending'; email: string }
> {
const email = recipient?.email?.trim();
const username = recipient?.username?.trim();
// Case and provider aliases resolve to the account; see #addressOwner.
const user = email
? await this.#addressOwner(email)
: username
? await this.stores.user.getByUsername(username)
: null;
// An unconfirmed email is a claim, not an identity: resolving it would
// hand the share to whoever registered the address first.
const unconfirmedEmailMatch = Boolean(email) && !user?.email_confirmed;
if (email && (!user?.username || unconfirmedEmailMatch)) {
return { kind: 'pending', email };
}
if (!user?.username) {
throw new HttpError(404, 'Recipient does not exist', {
legacyCode: 'user_does_not_exist',
});
}
return { kind: 'user', user };
}
/** Who holds this address. A rewritten local part needs a known domain. */
async #addressOwner(email: string): Promise<UserRow | null> {
const owner = await this.stores.user.findEmailOwner(email);
if (!owner?.email) return owner ?? null;
const sameAddress =
owner.email.trim().toLowerCase() === email.trim().toLowerCase();
if (sameAddress || isProviderCanonicalized(email)) return owner;
return null;
}
/**
* 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,
mode: AclMode = 'see',
): Promise<void> {
// Authority to share lives with the user: they own the node, or hold a
// `manage` grant on it. An app inherits that authority but is not the
// one who has it, so this asks the user behind the actor.
//
// Asked about the permission actually being handed out, not a fixed
// `read`. Handing out `manage` needs authority over `manage`, which
// only the owner has — `grantUserUserPermission` enforces that too, but
// two layers down and with a rawer error, so a caller who cannot do it
// should be turned away here.
const allowed = await this.services.permission.canManagePermission(
userRelatedActor(actor),
entryPermissionForMode(entry.uuid, mode),
);
// Reach is the second, independent bound: a credential may only hand
// out access it holds itself. For a session that is a no-op; for an app
// it is what keeps sharing to its own AppData and the files it was
// given, rather than everything its user owns.
if (allowed && (await this.#hasOwnReach(actor, entry, mode))) return;
// Only for someone who can already share here, so it leaks nothing.
if (mode === MANAGE_PERM_PREFIX) {
const canDelegateAccess =
await this.services.permission.canManagePermission(
userRelatedActor(actor),
entryPermissionForMode(entry.uuid, 'write'),
);
if (canDelegateAccess) {
throw new HttpError(
403,
'Only the owner can grant edit & share access',
{ legacyCode: 'cannot_delegate_manage' },
);
}
}
const safe = await this.services.acl.getSafeAclError(
actor,
this.#descriptorFor(entry),
'manage',
);
throw new HttpError(safe.status, safe.message, {
legacyCode: safe.fields.code,
});
}
/**
* Of `entries`, the uuids the acting credential reaches in its own right. A
* plain session reaches all of them; the checks only run for an app or a
* token, where each one is a cached scan.
*/
async #reachableBy(actor: Actor, entries: FSEntry[]): Promise<Set<string>> {
if (!actor.app && !actor.accessToken) {
return new Set(entries.map((entry) => entry.uuid));
}
const checks = await Promise.all(
entries.map(async (entry) =>
(await this.#hasOwnReach(actor, entry, 'see'))
? entry.uuid
: null,
),
);
return new Set(checks.filter((uuid): uuid is string => uuid !== null));
}
/** Whether the acting credential itself reaches `entry` at `mode`. */
async #hasOwnReach(
actor: Actor,
entry: FSEntry,
mode: AclMode,
): Promise<boolean> {
if (!actor.app && !actor.accessToken) return true;
return this.services.acl.check(actor, this.#descriptorFor(entry), mode);
}
/**
* Take a slot out of today's budget, returning the release for it.
*
* The increment is the check: it is atomic, so concurrent callers get
* distinct numbers and only those at or under the limit proceed. Counting
* first and writing after would let them all read the same count and pass.
*/
async #reserveDailyQuota(userId: number): Promise<() => Promise<void>> {
const limit =
this.config.share_daily_limit ?? DEFAULT_DAILY_SHARE_LIMIT;
const noop = async () => {};
if (limit <= 0) return noop;
const release = async (): Promise<void> => {
try {
await this.stores.share.incrementDailyShareCount(userId, -1);
} catch {
// A leaked slot costs the user one share until midnight;
// failing the request over it would cost them more.
}
};
const used = await this.stores.share.incrementDailyShareCount(userId);
if (used > limit) {
await release();
throw new HttpError(
429,
`daily share limit reached (${limit}); try again tomorrow`,
{ legacyCode: 'share_daily_limit_reached' },
);
}
return release;
}
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 isTrashedPath(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 authority only the owner has, so a delegate
* withdrawing a plain `read` would otherwise fail on reaching it.
*
* `authorized` is false when it could manage none of them — the caller must
* then leave the index row alone, or it hides a grant that is still live.
*/
async #revokeFor(
actor: Actor,
entry: FSEntry,
username: string,
issuerUserId: number,
): Promise<{ revoked: boolean; authorized: boolean }> {
const permissions = entryPermissions(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, authorized: manageable.some(Boolean) };
}
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.
}
}
}