diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 209da62620..c00a7d6eb3 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 62; +const CURRENT_SCHEMA_VERSION = 63; const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238'; const sqliteConfig = ( diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 6877d16c45..5e9e8550fb 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -96,6 +96,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [59, ['0064_abuse-moderation-events.sql']], [60, ['0065_app-feedback.sql']], [61, ['0066_owned-email-unique.sql']], + [62, ['0067_share_entries.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_22.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_22.sql new file mode 100644 index 0000000000..260d1b1cc2 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_22.sql @@ -0,0 +1,89 @@ +-- 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 . + +-- Grow `share` from a pending-email-invite table into the index of active +-- shares. See sqlite/0067_share_entries.sql for the column rationale. +-- +-- Idempotent: columns go through _puter_add_col (from mig_1), indexes and +-- foreign keys through the guarded procedure below. There is no per-file +-- applied-state tracking, so every statement must tolerate a re-run. + +CALL _puter_add_col('share', 'holder_user_id', '`holder_user_id` int unsigned DEFAULT NULL'); +CALL _puter_add_col('share', 'fsentry_id', '`fsentry_id` int unsigned DEFAULT NULL'); +CALL _puter_add_col('share', 'mode', '`mode` varchar(20) DEFAULT NULL'); +CALL _puter_add_col('share', 'applied_at', '`applied_at` timestamp NULL DEFAULT NULL'); + +DROP PROCEDURE IF EXISTS _puter_add_share_index_constraints; +DELIMITER // +CREATE PROCEDURE _puter_add_share_index_constraints() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'share' + AND INDEX_NAME = 'idx_share_holder' + ) THEN + ALTER TABLE `share` ADD INDEX `idx_share_holder` (`holder_user_id`, `id`); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'share' + AND INDEX_NAME = 'idx_share_fsentry' + ) THEN + ALTER TABLE `share` ADD INDEX `idx_share_fsentry` (`fsentry_id`); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'share' + AND INDEX_NAME = 'idx_share_holder_entry_issuer' + ) THEN + ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_holder_entry_issuer` + (`holder_user_id`, `fsentry_id`, `issuer_user_id`); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'share' + AND CONSTRAINT_NAME = 'share_holder_user_fk' + ) THEN + ALTER TABLE `share` ADD CONSTRAINT `share_holder_user_fk` + FOREIGN KEY (`holder_user_id`) REFERENCES `user` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + -- The cascade that retires a share with its file. + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'share' + AND CONSTRAINT_NAME = 'share_fsentry_fk' + ) THEN + ALTER TABLE `share` ADD CONSTRAINT `share_fsentry_fk` + FOREIGN KEY (`fsentry_id`) REFERENCES `fsentries` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END// +DELIMITER ; + +CALL _puter_add_share_index_constraints(); + +DROP PROCEDURE IF EXISTS _puter_add_share_index_constraints; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_11.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_11.sql new file mode 100644 index 0000000000..14d3f55771 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_11.sql @@ -0,0 +1,34 @@ +-- 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 . + +-- Grow share from a pending-email-invite table into the index of active +-- shares. See sqlite/0067_share_entries.sql for the column rationale. + +ALTER TABLE share + ADD COLUMN IF NOT EXISTS holder_user_id integer + REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE, + ADD COLUMN IF NOT EXISTS fsentry_id integer + REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE, + ADD COLUMN IF NOT EXISTS mode varchar(20), + ADD COLUMN IF NOT EXISTS applied_at timestamp; + +CREATE INDEX IF NOT EXISTS idx_share_holder + ON share (holder_user_id, id); +CREATE INDEX IF NOT EXISTS idx_share_fsentry + ON share (fsentry_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_share_holder_entry_issuer + ON share (holder_user_id, fsentry_id, issuer_user_id); diff --git a/src/backend/clients/database/migrations/sqlite/0067_share_entries.sql b/src/backend/clients/database/migrations/sqlite/0067_share_entries.sql new file mode 100644 index 0000000000..40941a198a --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0067_share_entries.sql @@ -0,0 +1,51 @@ +-- 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 . + +-- Grow `share` from a pending-email-invite table into the index of active +-- shares. Permissions stay the source of truth for access; this is what makes +-- shares listable and gives them a lifecycle. +-- +-- - `holder_user_id` : NULL while an invite awaits signup. +-- - `fsentry_id` : the shared node. ON DELETE CASCADE retires the share +-- with the file, which permissions alone don't do. +-- - `mode` : see|list|read|write|manage. Unconstrained on purpose — +-- ACLService owns the mode set and adding one shouldn't +-- need a three-dialect migration. +-- - `applied_at` : set when an invite is claimed. Claiming updates the +-- row rather than deleting it, so the share stays +-- queryable. + +ALTER TABLE `share` ADD COLUMN `holder_user_id` INTEGER DEFAULT NULL + REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `share` ADD COLUMN `fsentry_id` INTEGER DEFAULT NULL + REFERENCES `fsentries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE `share` ADD COLUMN `mode` TEXT DEFAULT NULL; +ALTER TABLE `share` ADD COLUMN `applied_at` TIMESTAMP DEFAULT NULL; + +-- "Shared with me", keyset-paginated: ORDER BY ends in `id` as the tiebreaker. +CREATE INDEX IF NOT EXISTS `idx_share_holder` + ON `share` (`holder_user_id`, `id`); + +-- Who has access to one node — also how an owner sees a manage-delegate's +-- re-grants, which the issuer/holder permission tables can't answer. +CREATE INDEX IF NOT EXISTS `idx_share_fsentry` ON `share` (`fsentry_id`); + +-- One row per (holder, node, issuer). Pending invites have a NULL +-- holder_user_id and so aren't covered here; dedup for those belongs with the +-- invite flow. +CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_holder_entry_issuer` + ON `share` (`holder_user_id`, `fsentry_id`, `issuer_user_id`); diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 7e137d3fca..d0e974b261 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -344,6 +344,21 @@ export type EventMap = { data?: unknown; ttlSeconds?: number; }; + /** + * Permission cache generations were bumped, so peer regions must bump their + * own — the counter is per-cluster, so a local bump says nothing to them. + * Carries the actors, not the values: the numbers only have to change. + */ + 'outer.permission.generationBumped': { actorUids: string[] }; + /** + * Flat permission entries were deleted. Grant-path flat entries carry no + * expiry, so without this a revoke never lands in a peer region whose KV + * table isn't replicated. Revoke-only: a grant that fails to replicate just + * denies there, which is the safe direction. + */ + 'outer.permission.flatInvalidated': { + entries: Array<{ holderUserId: number; permission: string }>; + }; 'outer.fs.write-hash': { hash: string; uuid: string }; /** * Cache keys the KV read cache must stop serving, because the entries diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index d2b678d8b4..a3fe56b737 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -25,7 +25,14 @@ import type { Actor } from '../../core/actor.js'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; import { Controller, Get, Post } from '../../core/http/decorators.js'; -import { assertNormalized } from '../../services/fs/resolveNode.js'; +import { + assertNormalized, + isOwnersTrash, +} from '../../services/fs/resolveNode.js'; +import { + maskEntryPath, + resolveSharePath, +} from '../../services/fs/sharePathMask.js'; import type { PreparedBatchWrite, UploadedBatchWriteItem, @@ -144,7 +151,7 @@ export class FSController extends PuterController { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); - requestBody.fileMetadata = this.#normalizeFileMetadataPath( + requestBody.fileMetadata = await this.#normalizeFileMetadataPath( req, requestBody.fileMetadata, requestBody, @@ -215,7 +222,7 @@ export class FSController extends PuterController { req.body, ); normalizedRequestBody.fileMetadata = - this.#normalizeFileMetadataPath( + await this.#normalizeFileMetadataPath( req, normalizedRequestBody.fileMetadata, normalizedRequestBody, @@ -435,7 +442,7 @@ export class FSController extends PuterController { const userId = this.#getActorUserId(req); const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req); const requestBody = this.#withGuiMetadata(req.body, req.body); - requestBody.fileMetadata = this.#normalizeFileMetadataPath( + requestBody.fileMetadata = await this.#normalizeFileMetadataPath( req, requestBody.fileMetadata, requestBody, @@ -449,7 +456,7 @@ export class FSController extends PuterController { await this.#assertWriteAccess(req, requestBody.fileMetadata, { pathAlreadyNormalized: true, }); - const normalizedPath = this.#normalizePath( + const normalizedPath = await this.#resolveClientPath( requestBody.fileMetadata.path, ); const uploadTracker = await this.#createUploadTracker( @@ -555,7 +562,7 @@ export class FSController extends PuterController { ...parsedManifest, items: parsedManifest.items.map((item) => ({ ...item, - fileMetadata: this.#normalizeFileMetadataPath( + fileMetadata: this.#expandFileMetadataPath( req, item.fileMetadata, item, @@ -586,7 +593,12 @@ export class FSController extends PuterController { ...item, fileMetadata: await this.#resolveAssociatedAppMetadata( - item.fileMetadata, + { + ...item.fileMetadata, + path: await this.#unmaskPath( + item.fileMetadata.path, + ), + }, item, appUidLookupCache, userId, @@ -831,7 +843,7 @@ export class FSController extends PuterController { req.body, ); normalizedRequestBody.fileMetadata = - this.#normalizeFileMetadataPath( + await this.#normalizeFileMetadataPath( req, normalizedRequestBody.fileMetadata, normalizedRequestBody, @@ -994,7 +1006,7 @@ export class FSController extends PuterController { uuid: entry.uuid, uid: entry.uid ?? entry.uuid, parentUid: entry.parentUid ?? null, - path: entry.path, + path: maskEntryPath(entry), name: entry.name, isDir: entry.isDir, isShortcut: entry.isShortcut, @@ -1110,7 +1122,6 @@ export class FSController extends PuterController { const rootChildren = await listRootEntries( actor, this.stores.fsEntry, - this.services.permission, ); const rootSuggestions = await this.services.suggestedApps.getSuggestedAppsForEntries( @@ -1385,7 +1396,7 @@ export class FSController extends PuterController { // would compute a wrong parent for `~/...` inputs (e.g. dirname of // `/~/Documents/foo` is `/~/Documents`, not `//Documents`). const username = this.#getActorUsername(req); - const path = this.#normalizePath(rawPath, username); + const path = await this.#resolveClientPath(rawPath, username); if (path === '/') throw new HttpError(400, 'Cannot mkdir at root', { legacyCode: 'bad_request', @@ -1426,7 +1437,7 @@ export class FSController extends PuterController { }); const username = this.#getActorUsername(req); - const path = this.#normalizePath(rawPath, username); + const path = await this.#resolveClientPath(rawPath, username); if (path === '/') throw new HttpError(400, 'Cannot touch root', { legacyCode: 'bad_request', @@ -1467,7 +1478,8 @@ export class FSController extends PuterController { const entry = await this.#resolveEntryForRequest(body); await this.#assertAccess(actor, entry.path, 'write'); - const renamed = await this.services.fs.rename(entry, newName); + const userId = this.#getActorUserId(req); + const renamed = await this.services.fs.rename(userId, entry, newName); this.#emitGuiItemUpdated(renamed); res.json(this.#toClientEntry(renamed)); } @@ -1511,7 +1523,9 @@ export class FSController extends PuterController { await this.#resolveEntryForRequest(destinationRef); await this.#assertAccess(actor, source.path, 'write'); - await this.#assertAccess(actor, destinationParent.path, 'write'); + if (!isOwnersTrash(source, destinationParent)) { + await this.#assertAccess(actor, destinationParent.path, 'write'); + } const moved = await this.services.fs.move(userId, { source, @@ -1623,7 +1637,7 @@ export class FSController extends PuterController { const ref = { path: rawPath !== undefined - ? mod.expandTildePath(rawPath, username) + ? await this.#resolveClientPath(rawPath, username) : undefined, uid: typeof source.uid === 'string' @@ -2178,7 +2192,8 @@ export class FSController extends PuterController { // the ActorUser type. Access via the escape hatch until a proper // storage-quota mechanism is in place. const actorUser = req.actor?.user as - Record | undefined; + | Record + | undefined; const candidates = [ this.#toStorageCapacityCandidate(actorUser?.free_storage), @@ -2191,6 +2206,18 @@ export class FSController extends PuterController { return Math.max(...candidates); } + /** + * `#normalizePath`, plus turning a masked share path back into the owner's + * real one. Every client-authored path goes through here. + */ + async #resolveClientPath(path: string, username?: string): Promise { + return resolveSharePath( + this.stores.fsEntry, + Context.get('actor'), + this.#normalizePath(path, username), + ); + } + #normalizePath(path: string, username?: string): string { const trimmedPath = path.trim(); if (trimmedPath.length === 0) { @@ -2221,7 +2248,7 @@ export class FSController extends PuterController { return normalizedPath; } - #normalizeFileMetadataPath( + #expandFileMetadataPath( req: Request, fileMetadata: FSEntryWriteInput | undefined, fallbackSource?: unknown, @@ -2236,13 +2263,38 @@ export class FSController extends PuterController { }); } - const username = this.#getActorUsername(req); return { ...resolvedFileMetadata, - path: this.#normalizePath(resolvedFileMetadata.path, username), + path: this.#normalizePath( + resolvedFileMetadata.path, + this.#getActorUsername(req), + ), }; } + /** As above, plus turning a masked share path into the owner's real one. */ + async #normalizeFileMetadataPath( + req: Request, + fileMetadata: FSEntryWriteInput | undefined, + fallbackSource?: unknown, + ): Promise { + const expanded = this.#expandFileMetadataPath( + req, + fileMetadata, + fallbackSource, + ); + return { ...expanded, path: await this.#unmaskPath(expanded.path) }; + } + + /** The un-masking half, for callers that already expanded the path. */ + async #unmaskPath(path: string): Promise { + return resolveSharePath( + this.stores.fsEntry, + Context.get('actor'), + path, + ); + } + #extractGuiMetadata( input: unknown, fallback: WriteGuiMetadata | undefined, @@ -2321,7 +2373,7 @@ export class FSController extends PuterController { } const normalizedFileMetadata = options?.pathAlreadyNormalized ? fileMetadata - : this.#normalizeFileMetadataPath(req, fileMetadata); + : await this.#normalizeFileMetadataPath(req, fileMetadata); if (!normalizedFileMetadata) { throw new HttpError(400, 'Missing path', { legacyCode: 'bad_request', @@ -2484,7 +2536,7 @@ export class FSController extends PuterController { requestBody: SignedWriteRequest, response: SignedWriteResponse, ): Promise { - const normalizedPath = this.#normalizePath( + const normalizedPath = await this.#resolveClientPath( requestBody.fileMetadata.path, ); const pendingResponse = { diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index 7b235ff59c..8969e7ac6c 100644 --- a/src/backend/controllers/fs/LegacyFSController.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.test.ts @@ -1166,6 +1166,115 @@ describe('LegacyFSController.move', () => { }; expect(removedPayload.response?.uid).toBe(replaced.uuid); }); + + it('lets a share recipient trash an item into the owner’s trash', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const ownerName = owner.actor.user!.username!; + const sharedPath = `/${ownerName}/Documents/Contents`; + + await withActor(owner.actor, () => + controller.mkdir( + makeReq({ body: { path: sharedPath }, actor: owner.actor }), + makeRes().res, + ), + ); + await withActor(owner.actor, () => + controller.touch( + makeReq({ + body: { path: `${sharedPath}/note.txt` }, + actor: owner.actor, + }), + makeRes().res, + ), + ); + const shared = (await server.stores.fsEntry.getEntryByPath(sharedPath))!; + await server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: shared.path, + resolveAncestors: () => + server.services.fs.getAncestorChain(shared.path), + }, + 'write', + ); + + const { res, captured } = makeRes(); + await withActor(holder.actor, () => + controller.move( + makeReq({ + body: { + source: `${sharedPath}/note.txt`, + destination: `/${ownerName}/Trash`, + }, + actor: holder.actor, + }), + res, + ), + ); + + // The move landed in the owner's trash, but the recipient is told so + // in masked form — the owner's real layout stays theirs. + const body = captured.body as { moved: { uid: string; path: string } }; + expect(body.moved.path).toBe( + `/${ownerName}/${body.moved.uid}/note.txt`, + ); + const moved = await server.stores.fsEntry.getEntryByUuid( + body.moved.uid, + ); + expect(moved!.path).toBe(`/${ownerName}/Trash/note.txt`); + }); + + it('refuses a share recipient moving an item into their own trash', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const ownerName = owner.actor.user!.username!; + const holderName = holder.actor.user!.username!; + const sharedPath = `/${ownerName}/Documents/Shared`; + + await withActor(owner.actor, () => + controller.mkdir( + makeReq({ body: { path: sharedPath }, actor: owner.actor }), + makeRes().res, + ), + ); + await withActor(owner.actor, () => + controller.touch( + makeReq({ + body: { path: `${sharedPath}/theirs.txt` }, + actor: owner.actor, + }), + makeRes().res, + ), + ); + const shared = (await server.stores.fsEntry.getEntryByPath(sharedPath))!; + await server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: shared.path, + resolveAncestors: () => + server.services.fs.getAncestorChain(shared.path), + }, + 'write', + ); + + await expect( + withActor(holder.actor, () => + controller.move( + makeReq({ + body: { + source: `${sharedPath}/theirs.txt`, + destination: `/${holderName}/Trash`, + }, + actor: holder.actor, + }), + makeRes().res, + ), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); }); // ── search ────────────────────────────────────────────────────────── @@ -1586,8 +1695,11 @@ describe('LegacyFSController.writeFile (write IDOR)', () => { ), ); - // The write landed on the signed file … - expect((captured.body as { path?: string }).path).toBe(target); + // The write landed on the signed file — reported masked, since the + // attacker doesn't own it … + expect((captured.body as { path?: string }).path).toBe( + `/${victim.actor.user!.username}/${entry!.uuid}/secret.txt`, + ); // … and never created the attacker-named sibling. const siblingEntry = await server.stores.fsEntry.getEntryByPath(sibling); diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 2d8ae6dfcf..a633572683 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -37,7 +37,11 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js'; import type { ACLService } from '../../services/acl/ACLService.js'; import { assertActorHasCredits } from '../../services/metering/enforcement.js'; import type { SignedFile } from '../../util/fileSigning.js'; -import { verifySignature } from '../../util/fileSigning.js'; +import { + NON_OWNER_SIGNATURE_TTL_SECONDS, + verifySignature, +} from '../../util/fileSigning.js'; +import { maskEntryPath } from '../../services/fs/sharePathMask.js'; import { buildHostedBackingDenial, hostedIndexUrlBackingIsUnavailable, @@ -66,6 +70,7 @@ import { asRecord, assertAccess, assertCanCreate, + assertCanMoveInto, getBoolean, getString, loadLegacyAssociatedApps, @@ -484,7 +489,6 @@ export class LegacyFSController extends PuterController { const rootChildren = await listRootEntries( actor, this.stores.fsEntry, - this.services.permission, ); const rootSuggestions = await this.services.suggestedApps.getSuggestedAppsForEntries( @@ -790,12 +794,12 @@ export class LegacyFSController extends PuterController { source.path, 'write', ); - await assertAccess( + await assertCanMoveInto( this.services.acl, this.services.fs, actor, - destinationParent.path, - 'write', + source, + destinationParent, ); // The v1 wire contract reports the entry an overwrite replaced @@ -828,7 +832,9 @@ export class LegacyFSController extends PuterController { // Trash, and `null`/`{}` when restoring. See // `src/gui/src/helpers.js` → `window.move_items`. newMetadata: (body.new_metadata ?? undefined) as - Record | null | undefined, + | Record + | null + | undefined, }); const oldPath = source.path; await this.#emitGuiEvent('outer.gui.item.moved', moved, { @@ -944,7 +950,8 @@ export class LegacyFSController extends PuterController { 'write', ); - const renamed = await this.services.fs.rename(entry, newName); + const userId = this.#getActorUserId(req); + const renamed = await this.services.fs.rename(userId, entry, newName); await this.#emitGuiEvent('outer.gui.item.updated', renamed); res.json(await toLegacyEntry(this.clients.event, renamed)); }; @@ -1274,7 +1281,8 @@ export class LegacyFSController extends PuterController { } type SignedOrEmpty = - (SignedFile & { path?: string }) | Record; + | (SignedFile & { path?: string }) + | Record; const result: { signatures: SignedOrEmpty[]; token?: string } = { signatures: [], }; @@ -1334,7 +1342,7 @@ export class LegacyFSController extends PuterController { const writeOk = await this.services.acl.check( actor, { - path: entry.path, + path: maskEntryPath(entry), resolveAncestors: () => this.services.fs.getAncestorChain(entry.path), }, @@ -1354,12 +1362,20 @@ export class LegacyFSController extends PuterController { ); } - const signed = signEntry(entry, signingCfg); + const signed = signEntry(entry, signingCfg, { + actorUserId: actor.user?.id, + }); if (finalAction !== 'write') { const { write_url: _, ...rest } = signed; - result.signatures.push({ ...rest, path: entry.path }); + result.signatures.push({ + ...rest, + path: maskEntryPath(entry), + }); } else { - result.signatures.push({ ...signed, path: entry.path }); + result.signatures.push({ + ...signed, + path: maskEntryPath(entry), + }); } } catch { // Silently skip unresolvable items. @@ -1465,8 +1481,10 @@ export class LegacyFSController extends PuterController { 'outer.gui.item.added', uploadResult.fsEntry, ); - const signed = signEntry(uploadResult.fsEntry, signingCfg); - res.json({ ...signed, path: uploadResult.fsEntry.path }); + const signed = signEntry(uploadResult.fsEntry, signingCfg, { + actorUserId: callerActor.user?.id, + }); + res.json({ ...signed, path: maskEntryPath(uploadResult.fsEntry) }); return; } @@ -1494,7 +1512,12 @@ export class LegacyFSController extends PuterController { dedupeName: true, }); await this.#emitGuiEvent('outer.gui.item.added', entry); - res.json({ ...signEntry(entry, signingCfg), path: entry.path }); + res.json({ + ...signEntry(entry, signingCfg, { + actorUserId: callerActor.user?.id, + }), + path: maskEntryPath(entry), + }); return; } if (operation === 'rename') { @@ -1511,9 +1534,18 @@ export class LegacyFSController extends PuterController { targetEntry.path, 'write', ); - const renamed = await this.services.fs.rename(targetEntry, newName); + const renamed = await this.services.fs.rename( + userId, + targetEntry, + newName, + ); await this.#emitGuiEvent('outer.gui.item.updated', renamed); - res.json({ ...signEntry(renamed, signingCfg), path: renamed.path }); + res.json({ + ...signEntry(renamed, signingCfg, { + actorUserId: callerActor.user?.id, + }), + path: maskEntryPath(renamed), + }); return; } if (operation === 'delete' || operation === 'trash') { @@ -1581,7 +1613,12 @@ export class LegacyFSController extends PuterController { ? { old_path: targetEntry.path } : undefined, ); - res.json({ ...signEntry(result, signingCfg), path: result.path }); + res.json({ + ...signEntry(result, signingCfg, { + actorUserId: callerActor.user?.id, + }), + path: maskEntryPath(result), + }); return; } @@ -1643,11 +1680,18 @@ export class LegacyFSController extends PuterController { // Directory: return a signed listing of direct children. // The caller only proved read access, so strip write_url from // each child to prevent privilege escalation via /writeFile. + // + // Child paths are the owner's real ones — there is no actor here to + // mask for. Accepted: a signature is minted and handed out by the + // owner, so the layout it reveals is theirs to reveal, and the child + // signatures expire; the paths are the only part that outlives them. if (entry.isDir) { const children = await this.services.fs.listDirectory(entry.uuid); const signedChildren = children.map((child) => { - const { write_url: _, ...rest } = signEntry(child, signingCfg); - return { ...rest, path: child.path }; + const { write_url: _, ...rest } = signEntry(child, signingCfg, { + ttlSeconds: NON_OWNER_SIGNATURE_TTL_SECONDS, + }); + return { ...rest, path: maskEntryPath(child) }; }); res.json(signedChildren); return; @@ -1744,7 +1788,7 @@ export class LegacyFSController extends PuterController { const writeOk = await this.services.acl.check( actor, { - path: entry.path, + path: maskEntryPath(entry), resolveAncestors: () => this.services.fs.getAncestorChain(entry.path), }, @@ -1754,7 +1798,7 @@ export class LegacyFSController extends PuterController { const suggested = (await this.services.suggestedApps?.getSuggestedApps({ name: entry.name, - path: entry.path, + path: maskEntryPath(entry), })) ?? []; let token: string | null = null; @@ -1777,12 +1821,14 @@ export class LegacyFSController extends PuterController { } const signingCfg = signingConfigFromAppConfig(this.config); - const signed = signEntry(entry, signingCfg); + const signed = signEntry(entry, signingCfg, { + actorUserId: actor.user?.id, + }); const signature = writeOk - ? { ...signed, path: entry.path } + ? { ...signed, path: maskEntryPath(entry) } : (() => { const { write_url: _, ...rest } = signed; - return { ...rest, path: entry.path }; + return { ...rest, path: maskEntryPath(entry) }; })(); res.json({ signature, @@ -1840,7 +1886,10 @@ export class LegacyFSController extends PuterController { const subjectRef = body.subject; const appRef = body.app; const mode = (getString(body, 'mode') ?? 'read') as - 'see' | 'list' | 'read' | 'write'; + | 'see' + | 'list' + | 'read' + | 'write'; if (!subjectRef || !appRef) throw new HttpError(400, '`subject` and `app` are required', { legacyCode: 'bad_request', @@ -2296,12 +2345,12 @@ export class LegacyFSController extends PuterController { source.path, 'write', ); - await assertAccess( + await assertCanMoveInto( this.services.acl, this.services.fs, actor, - destinationParent.path, - 'write', + source, + destinationParent, ); const moved = await this.services.fs.move(userId, { source, diff --git a/src/backend/controllers/fs/legacyFsHelpers.test.ts b/src/backend/controllers/fs/legacyFsHelpers.test.ts index f786f01219..51021ec6ab 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.test.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.test.ts @@ -24,11 +24,13 @@ import { getBoolean, getString, loadLegacyAssociatedApps, + signEntry, signEntryThumbnail, signingConfigFromAppConfig, toLegacyEntry, } from './legacyFsHelpers.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; +import { NON_OWNER_SIGNATURE_TTL_SECONDS } from '../../util/fileSigning.js'; const entryWithApp = (associatedAppId: number): FSEntry => ({ associatedAppId }) as unknown as FSEntry; @@ -444,3 +446,48 @@ describe('loadLegacyAssociatedApps short-circuit', () => { expect(seen).toEqual([[4, 5]]); }); }); + +describe('signEntry', () => { + const cfg = { secret: 'test-secret', apiBaseUrl: 'https://api.test' }; + const entry = { + uuid: 'e-1', + name: 'x.txt', + isDir: false, + size: 1, + accessed: 0, + modified: 0, + created: 0, + userId: 7, + }; + // signFile ceils its own clock read, so allow a second of slack. + const inSeconds = (expires: number) => + expires - Math.ceil(Date.now() / 1000); + + it('leaves an owner’s signature effectively permanent', () => { + const signed = signEntry(entry, cfg, { actorUserId: 7 }); + expect(inSeconds(signed.expires)).toBeGreaterThan( + NON_OWNER_SIGNATURE_TTL_SECONDS * 10, + ); + }); + + it('expires a signature over someone else’s entry', () => { + const signed = signEntry(entry, cfg, { actorUserId: 8 }); + const ttl = inSeconds(signed.expires); + expect(ttl).toBeLessThanOrEqual(NON_OWNER_SIGNATURE_TTL_SECONDS); + expect(ttl).toBeGreaterThan(0); + }); + + it('keeps the old default when no actor is supplied', () => { + expect(inSeconds(signEntry(entry, cfg).expires)).toBeGreaterThan( + NON_OWNER_SIGNATURE_TTL_SECONDS * 10, + ); + }); + + it('honours an explicit ttl over the ownership check', () => { + const signed = signEntry(entry, cfg, { + actorUserId: 7, + ttlSeconds: 30, + }); + expect(inSeconds(signed.expires)).toBeLessThanOrEqual(30); + }); +}); diff --git a/src/backend/controllers/fs/legacyFsHelpers.ts b/src/backend/controllers/fs/legacyFsHelpers.ts index 06066e2928..1f8a36aaa6 100644 --- a/src/backend/controllers/fs/legacyFsHelpers.ts +++ b/src/backend/controllers/fs/legacyFsHelpers.ts @@ -30,10 +30,16 @@ import { HttpError } from '../../core/http/HttpError.js'; import { resolveNode, normalizeAbsolutePath, + isOwnersTrash, joinChildPath, expandTildePath, } from '../../services/fs/resolveNode.js'; import { + maskEntryPath, + resolveSharePath, +} from '../../services/fs/sharePathMask.js'; +import { + NON_OWNER_SIGNATURE_TTL_SECONDS, signFile, type SigningConfig, type SignedFile, @@ -93,6 +99,22 @@ export function getBoolean( // username, read from the ALS-backed Context. Legacy clients send tilde- // rooted paths (e.g. `~/AppData//...`); FSController does the same // expansion via its own `#normalizePath` helper. +/** + * Everything a client-authored path needs before it names a row: `~` expanded, + * and a masked share path turned back into the owner's real one. + */ +export async function expandClientPath( + fsEntryStore: FSEntryStore, + raw: string, + username?: string, +): Promise { + return resolveSharePath( + fsEntryStore, + Context.get('actor'), + expandTildePath(raw, username), + ); +} + export async function resolveV1Selector( fsEntryStore: FSEntryStore, raw: unknown, @@ -106,7 +128,7 @@ export async function resolveV1Selector( if (typeof raw === 'string') { const isPath = raw.startsWith('/') || raw.startsWith('~'); const ref = isPath - ? { path: expandTildePath(raw, username) } + ? { path: await expandClientPath(fsEntryStore, raw, username) } : { uid: raw }; const entry = await resolveNode(fsEntryStore, ref, { required: true }); if (!entry) @@ -138,7 +160,7 @@ export async function resolveV1Selector( const ref = { path: rawPath !== undefined - ? expandTildePath(rawPath, username) + ? await expandClientPath(fsEntryStore, rawPath, username) : undefined, uid: typeof record.uid === 'string' @@ -260,6 +282,24 @@ export async function assertCanCreate( await assertAccess(aclService, fsService, actor, parentForCheck, 'write'); } +/** `write` on the destination parent, unless it is the entry's own Trash. */ +export async function assertCanMoveInto( + aclService: ACLService, + fsService: FSService, + actor: Actor, + source: FSEntry, + destinationParent: FSEntry, +): Promise { + if (isOwnersTrash(source, destinationParent)) return; + await assertAccess( + aclService, + fsService, + actor, + destinationParent.path, + 'write', + ); +} + // -- Response shaping ------------------------------------------------ type AppRowLookup = { @@ -411,7 +451,10 @@ export async function toLegacyEntry( appsById?: Map>; } = {}, ): Promise> { - const dirname = pathPosix.dirname(entry.path); + // Someone else's entry is published under its masked path; the owner's + // real one, and everything above the share, stays server-side. + const publishedPath = maskEntryPath(entry); + const dirname = pathPosix.dirname(publishedPath); const mimeType = fsEntryMimeType(entry); const pathComponents = entry.path.split('/'); @@ -424,7 +467,7 @@ export async function toLegacyEntry( uuid: entry.uuid, parent_id: entry.parentUid, parent_uid: entry.parentUid, - path: entry.path, + path: publishedPath, dirname, dirpath: dirname, name: entry.name, @@ -520,7 +563,13 @@ export function signingConfigFromAppConfig(config: IConfig): SigningConfig { return { secret, apiBaseUrl }; } -/** Convenience wrapper: turn an FSEntry into a signed-file response object. */ +/** + * Convenience wrapper: turn an FSEntry into a signed-file response object. + * + * Pass `actorUserId` so a signature over someone else's entry — a shared file — + * expires rather than outliving the share (see + * NON_OWNER_SIGNATURE_TTL_SECONDS). + */ export function signEntry( entry: { uuid: string; @@ -530,8 +579,19 @@ export function signEntry( accessed: number | null; modified: number; created: number | null; + userId?: number; }, config: SigningConfig, + opts: { actorUserId?: number; ttlSeconds?: number } = {}, ): SignedFile { - return signFile(entry as Parameters[0], config); + const isForeign = + typeof opts.actorUserId === 'number' && + typeof entry.userId === 'number' && + entry.userId !== opts.actorUserId; + const ttlSeconds = + opts.ttlSeconds ?? + (isForeign ? NON_OWNER_SIGNATURE_TTL_SECONDS : undefined); + return signFile(entry as Parameters[0], config, { + ...(ttlSeconds === undefined ? {} : { ttlSeconds }), + }); } diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts new file mode 100644 index 0000000000..cd69f17fbd --- /dev/null +++ b/src/backend/controllers/share/ShareController.http.test.ts @@ -0,0 +1,303 @@ +/* + * 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 . + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +/** + * Route-level coverage for the sharing endpoints. The service unit tests drive + * the semantics; this suite exists to catch a route that was never registered, + * a gate that rejects a legitimate request, and anything the response shape + * leaks. + */ +describe('share endpoints over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const post = (path: string, token: string, body: unknown) => + fetch(new URL(path, env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const get = (path: string, token: string, params: Record) => { + const url = new URL(path, env.apiOrigin); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + return fetch(url, { headers: { authorization: `Bearer ${token}` } }); + }; + + /** A file in the owner's home. Written directly — these tests are about + * the share routes, not the upload path. */ + const makeFile = async (owner: { username: string }) => { + const uid = crypto.randomUUID(); + const name = `share-http-${uid.slice(0, 8)}.txt`; + const path = `/${owner.username}/${name}`; + const user = await env.server.stores.user.getByUsername(owner.username); + await env.server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)', + [uid, name, path, user!.id, Math.floor(Date.now() / 1000)], + ); + return { uid, path }; + }; + + it('shares an item, lists it for the recipient, then revokes it', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const file = await makeFile(owner); + + const shareRes = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(shareRes.status).toBe(200); + const shareBody = (await shareRes.json()) as { + status: string; + results: Array<{ status: string; mode?: string }>; + }; + expect(shareBody.status).toBe('success'); + expect(shareBody.results[0].mode).toBe('read'); + + const listRes = await get( + '/share/shared-with-me', + recipient.token, + { includeTotal: 'true' }, + ); + expect(listRes.status).toBe(200); + const listed = (await listRes.json()) as { + items: Array>; + total?: number; + }; + const row = listed.items.find((i) => i.uid_entry === file.uid); + expect(row).toBeDefined(); + expect(row?.issuer).toBe(owner.username); + expect(row?.mode).toBe('read'); + expect(typeof listed.total).toBe('number'); + + // Nothing internal rides along in the response. + for (const key of ['issuer_user_id', 'holder_user_id', 'fsentry_id']) { + expect(row).not.toHaveProperty(key); + } + + const revokeRes = await post('/share/revoke', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + }); + expect(revokeRes.status).toBe(200); + expect(await revokeRes.json()).toMatchObject({ revoked: 1 }); + + const afterRes = await get('/share/shared-with-me', recipient.token, {}); + const after = (await afterRes.json()) as { + items: Array>; + }; + expect(after.items.find((i) => i.uid_entry === file.uid)).toBeUndefined(); + }); + + it('revokes every item in the request, not just the first', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const fileA = await makeFile(owner); + const fileB = await makeFile(owner); + + for (const file of [fileA, fileB]) { + const res = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(res.status).toBe(200); + } + + // A truncated revoke is a silent security failure: the caller is told + // "success" while items after the first keep their grants. + const revokeRes = await post('/share/revoke', owner.token, { + recipients: [recipient.username], + items: [{ uid: fileA.uid }, { uid: fileB.uid }], + }); + expect(revokeRes.status).toBe(200); + expect(await revokeRes.json()).toMatchObject({ + status: 'success', + revoked: 2, + }); + + const afterRes = await get('/share/shared-with-me', recipient.token, {}); + const after = (await afterRes.json()) as { + items: Array>; + }; + for (const file of [fileA, fileB]) { + expect( + after.items.find((i) => i.uid_entry === file.uid), + ).toBeUndefined(); + } + }); + + it('accepts tilde-rooted paths the way the FS routes do', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const file = await makeFile(owner); + const tildePath = `~/${file.path.split('/').pop()}`; + + const shareRes = await post('/share', owner.token, { + recipients: [recipient.username], + items: [tildePath], + mode: 'read', + }); + expect(shareRes.status).toBe(200); + expect(await shareRes.json()).toMatchObject({ status: 'success' }); + + const listRes = await get('/share/shares', owner.token, { + path: tildePath, + }); + expect(listRes.status).toBe(200); + const listed = (await listRes.json()) as { + items: Array<{ holder: string }>; + }; + expect( + listed.items.some((i) => i.holder === recipient.username), + ).toBe(true); + + const revokeRes = await post('/share/revoke', owner.token, { + recipients: [recipient.username], + items: [tildePath], + }); + expect(revokeRes.status).toBe(200); + expect(await revokeRes.json()).toMatchObject({ revoked: 1 }); + }); + + it('reports per-pair outcomes when only some recipients resolve', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + + const res = await post('/share', owner.token, { + recipients: [env.users.other.username, 'nosuchuser-zzz'], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + status: string; + results: Array<{ status: string; recipient: string }>; + }; + expect(body.status).toBe('mixed'); + expect(body.results).toHaveLength(2); + expect( + body.results.find((r) => r.recipient === 'nosuchuser-zzz')?.status, + ).toBe('error'); + }); + + it('lists who can reach an item for its owner', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const file = await makeFile(owner); + + await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'write', + }); + + const res = await get('/share/shares', owner.token, { uid: file.uid }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array<{ holder: string; mode: string }>; + }; + expect(body.items).toHaveLength(1); + expect(body.items[0].holder).toBe(recipient.username); + expect(body.items[0].mode).toBe('write'); + }); + + it('hides an item from a stranger asking who can reach it', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + + const res = await get('/share/shares', env.users.other.token, { + uid: file.uid, + }); + expect(res.status).toBe(404); + }); + + it('rejects an unauthenticated share', async () => { + const res = await fetch(new URL('/share', env.apiOrigin), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ recipients: ['x'], items: ['y'] }), + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('caps how many recipients one request can reach', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + const many = Array.from({ length: 64 }, (_, i) => `user-${i}`); + + const res = await post('/share', owner.token, { + recipients: many, + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + code: 'too_many_recipients', + }); + }); + + it('caps how many items one request can carry', async () => { + const owner = env.users.user; + const many = Array.from({ length: 128 }, () => ({ + uid: crypto.randomUUID(), + })); + + const res = await post('/share', owner.token, { + recipients: [env.users.other.username], + items: many, + mode: 'read', + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: 'too_many_items' }); + }); + + it('rejects a request with no recipients or no items', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + + expect( + (await post('/share', owner.token, { items: [{ uid: file.uid }] })) + .status, + ).toBe(400); + expect( + ( + await post('/share', owner.token, { + recipients: [env.users.other.username], + }) + ).status, + ).toBe(400); + }); +}); diff --git a/src/backend/controllers/share/ShareController.ts b/src/backend/controllers/share/ShareController.ts index 03a8661fa7..c9b211cf2b 100644 --- a/src/backend/controllers/share/ShareController.ts +++ b/src/backend/controllers/share/ShareController.ts @@ -17,381 +17,444 @@ * along with this program. If not, see . */ -// import type { Request, Response } from 'express'; -// import { HttpError } from '../../core/http/HttpError.js'; -import type { PuterRouter } from '../../core/http/PuterRouter.js'; +import type { Request, Response } from 'express'; +import type { Actor } from '../../core/actor.js'; +import { Controller, Get, Post } from '../../core/http/decorators.js'; +import { HttpError, isHttpError } from '../../core/http/HttpError.js'; +import type { + ResolvedShare, + ShareRecipient, + ShareTarget, +} from '../../services/share/ShareService.js'; +import { expandTildePath } from '../../services/fs/resolveNode.js'; +import { signEntryThumbnail } from '../fs/legacyFsHelpers.js'; +import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js'; +import { normalizeLimit } from '../../util/pagination.js'; import { PuterController } from '../types.js'; -// const SHARE_TOKEN_TYPE = 'share'; -// const SHARE_TOKEN_EXPIRY = '14d'; +/** + * Two windows: a burst ceiling, and a daily one so a slow drip can't add up to + * a mail-merge. Neither bounds _shares_ — one request carries many — which is + * what `ShareService`'s per-day quota is for. + */ +const SHARE_LIMIT = [ + { scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' as const }, + { + scope: 'share:mutate-daily', + limit: 500, + window: 24 * 60 * 60_000, + key: 'user' as const, + }, +]; + +const SHARE_LIST_LIMIT = { + scope: 'share:list', + limit: 600, + window: 60_000, + key: 'user' as const, +}; + +/** Distinct (holder, item) pairs run together; see the note on grouping below. */ +const SHARE_CONCURRENCY = 8; +const LIST_LIMIT_CAP = 200; + +/** + * Caps on one request's fan-out. Recipients matter most: that number is how + * many people a single call can reach, so it stays small by default and only + * moves by configuration. + */ +export const DEFAULT_MAX_RECIPIENTS = 10; +export const DEFAULT_MAX_ITEMS = 50; + +/** A success carries the created share; a failure carries why. */ +interface ShareOutcome { + recipient: string; + status: 'success' | 'error'; + path?: string; + uid?: string; + mode?: string; + uid_entry?: string; + is_dir?: boolean; + issuer?: string | null; + holder?: string | null; + created_at?: unknown; + message?: string; + code?: string; +} /** - * Share link endpoints — check, apply, and request access to pending shares. - * The main `POST /share` creation endpoint is also here. + * Sharing endpoints. `ShareService` owns the semantics; this layer parses + * input, bounds fan-out, and shapes responses. * - * Shares are permission grants addressed to an email. When the recipient - * doesn't have a Puter account yet, the share row lives in the `share` table - * until they sign up and apply it. When they DO have an account, permissions - * are granted immediately and no row is stored. + * Apps and tokens are admitted rather than gated out, because `ShareService` + * bounds them properly: authority to share comes from the user behind the + * actor, and the actor must additionally reach the node in its own right. An + * app therefore shares its own AppData and the files it was given, and nothing + * else its user happens to own. */ +@Controller('/share') export class ShareController extends PuterController { - registerRoutes(_router: PuterRouter): void { - // const api = { subdomain: 'api' } as const; - // router.post('/sharelink/check', api, this.#check); - // router.post( - // '/sharelink/apply', - // { ...api, requireAuth: true }, - // this.#apply, - // ); - // router.post( - // '/sharelink/request', - // { ...api, requireAuth: true }, - // this.#request, - // ); - // router.post('/share', { ...api, requireAuth: true }, this.#share); + /** + * POST /share — grant `mode` on one or more items to one or more + * recipients. Partial success is the contract: each pair reports its own + * outcome and the envelope summarizes. + */ + @Post('', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIMIT, + }) + async createShares(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const body = this.#body(req); + const recipients = this.#recipients(body); + const items = this.#items(body, actor); + const mode = typeof body.mode === 'string' ? body.mode : 'read'; + + // Every (recipient, item) pair is a distinct (holder, entry) key, so + // they can run together. Two writes to the *same* pair could not — + // setUserUser is a read-modify-write. + const pairs = recipients.flatMap((recipient) => + items.map((item) => ({ recipient, item })), + ); + + const settled = await runWithConcurrencyLimitSettled( + pairs, + SHARE_CONCURRENCY, + async ({ recipient, item }) => { + const share = await this.services.share.share(actor, { + ...item, + recipient, + mode: mode as never, + }); + return share; + }, + ); + + const results: ShareOutcome[] = await Promise.all( + settled.map(async (outcome, index) => { + const { recipient, item } = pairs[index]; + const label = recipient.email ?? recipient.username ?? ''; + if (outcome.status === 'fulfilled') { + // The whole share, not just an acknowledgement, so a caller + // needn't re-read to learn what it created. + const share = outcome.value as ResolvedShare; + return { + ...(await this.#toClientShare(share)), + recipient: label, + status: 'success', + }; + } + return { + recipient: label, + ...(item.path ? { path: item.path } : {}), + status: 'error', + ...this.#errorShape(outcome.reason), + }; + }), + ); + + // Off the response path — a share must not fail because its + // notification didn't land. + void this.services.share + .notifyRecipients( + actor, + settled.flatMap((outcome) => + outcome.status === 'fulfilled' ? [outcome.value] : [], + ), + ) + .catch(() => {}); + + const succeeded = results.filter((r) => r.status === 'success').length; + res.json({ + status: + succeeded === results.length + ? 'success' + : succeeded > 0 + ? 'mixed' + : 'aborted', + results, + }); } - // // -- POST /sharelink/check --------------------------------------- - // // Public — verify a share token from an email link. - - // #check = async (req: Request, res: Response): Promise => { - // const token = req.body?.token; - // if (typeof token !== 'string' || token.length === 0) { - // throw new HttpError(400, 'Missing `token`'); - // } - - // let decoded: { uid?: string; type?: string }; - // try { - // decoded = this.services.token.verify(SHARE_TOKEN_TYPE, token); - // } catch { - // throw new HttpError(400, 'Invalid or expired share token'); - // } - // if (decoded.type !== `token:${SHARE_TOKEN_TYPE}` || !decoded.uid) { - // throw new HttpError(400, 'Invalid share token'); - // } - - // const share = await this.stores.share.getByUid(decoded.uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); - - // res.json({ - // $: 'api:share', - // uid: share.uid, - // email: share.recipient_email, - // }); - // }; - - // // -- POST /sharelink/apply --------------------------------------- - // // Auth required — apply a pending share's permissions to the caller. - - // #apply = async (req: Request, res: Response): Promise => { - // const uid = req.body?.uid; - // if (typeof uid !== 'string') throw new HttpError(400, 'Missing `uid`'); - - // const actor = req.actor; - // if (!actor?.user) throw new HttpError(401, 'Unauthorized'); - - // const share = await this.stores.share.getByUid(uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); - - // // Issuer must still exist - // const issuer = await this.stores.user.getById(share.issuer_user_id); - // if (!issuer) - // throw new HttpError(410, 'Share expired — issuer account gone'); - - // // Email must be confirmed - // if ( - // actor.user.requires_email_confirmation && - // !actor.user.email_confirmed - // ) { - // throw new HttpError( - // 403, - // 'Please confirm your email before applying shares', - // ); - // } - - // // Recipient email must match - // if ( - // !actor.user.email || - // actor.user.email.toLowerCase() !== - // share.recipient_email.toLowerCase() - // ) { - // throw new HttpError( - // 403, - // 'This share was sent to a different email address', - // ); - // } - - // // Grant each permission - // const issuerActor = { - // user: { - // id: issuer.id, - // uuid: issuer.uuid, - // username: issuer.username, - // email: issuer.email ?? null, - // suspended: false, - // email_confirmed: true, - // requires_email_confirmation: false, - // }, - // } as import('../../core/actor.js').Actor; - // const data = (share.data ?? {}) as { - // permissions?: Array<{ - // permission: string; - // extra?: Record; - // }>; - // }; - // for (const perm of data.permissions ?? []) { - // try { - // await this.services.permission.grantUserUserPermission( - // issuerActor, - // actor.user.username ?? '', - // perm.permission, - // perm.extra ?? {}, - // ); - // } catch (err) { - // console.warn('[share] grant failed for', perm.permission, err); - // } - // } - - // // Share consumed — delete it - // await this.stores.share.deleteByUid(uid); - - // res.json({ $: 'api:status-report', status: 'success' }); - // }; - - // // -- POST /sharelink/request ------------------------------------- - // // Auth required — notify the issuer that someone is requesting access. - - // #request = async (req: Request, res: Response): Promise => { - // const uid = req.body?.uid; - // if (typeof uid !== 'string') throw new HttpError(400, 'Missing `uid`'); - - // const actor = req.actor; - // if (!actor?.user) throw new HttpError(401, 'Unauthorized'); - - // const share = await this.stores.share.getByUid(uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); - - // const issuer = await this.stores.user.getById(share.issuer_user_id); - // if (!issuer) - // throw new HttpError(410, 'Share expired — issuer account gone'); - - // // If caller IS the intended recipient (confirmed email matches), - // // they should just /apply instead. - // if ( - // actor.user.email_confirmed && - // actor.user.email?.toLowerCase() === - // share.recipient_email.toLowerCase() - // ) { - // throw new HttpError( - // 400, - // 'You are the intended recipient — use /sharelink/apply instead', - // ); - // } - - // // Notify the issuer - // if (this.services.notification) { - // await this.services.notification.notify([issuer.id], { - // source: 'sharing', - // title: `User ${actor.user.username} is trying to open a share you sent to ${share.recipient_email}`, - // template: 'user-requesting-share', - // fields: { - // username: actor.user.username, - // intended_recipient: share.recipient_email, - // permissions: - // (share.data as Record)?.permissions ?? - // [], - // }, - // }); - // } - - // res.json({ $: 'api:status-report', status: 'success' }); - // }; - - // // -- POST /share ------------------------------------------------- - // // Auth required — create shares for recipients (users or emails). - - // #share = async (req: Request, res: Response): Promise => { - // const actor = req.actor; - // if (!actor?.user) throw new HttpError(401, 'Unauthorized'); - - // const body = req.body ?? {}; - // let recipients = body.recipients; - // let shares = body.shares; - // const dryRun = !!body.dry_run; - - // if (!recipients) throw new HttpError(400, 'Missing `recipients`'); - // if (!shares) throw new HttpError(400, 'Missing `shares`'); - // if (!Array.isArray(recipients)) recipients = [recipients]; - // if (!Array.isArray(shares)) shares = [shares]; - - // // Build the permissions list from share declarations. - // const permissions = this.#resolvePermissions(shares as unknown[]); - - // const recipientResults: unknown[] = []; - - // for (const recipient of recipients as unknown[]) { - // const recipientStr = - // typeof recipient === 'string' ? recipient.trim() : ''; - // if (!recipientStr) { - // recipientResults.push({ - // $: 'error', - // message: 'empty recipient', - // }); - // continue; - // } - - // try { - // // Try username first - // const targetUser = - // (await this.stores.user.getByUsername(recipientStr)) ?? - // (recipientStr.includes('@') - // ? await this.stores.user.getByEmail(recipientStr) - // : null); - - // if (targetUser) { - // // Direct grant — user exists - // if (!dryRun) { - // for (const perm of permissions) { - // try { - // await this.services.permission.grantUserUserPermission( - // actor, - // targetUser.username ?? '', - // perm.permission, - // perm.extra ?? {}, - // ); - // } catch (err) { - // console.warn( - // '[share] grant to user failed', - // perm.permission, - // err, - // ); - // } - // } - - // // Notify - // if (this.services.notification) { - // await this.services.notification.notify( - // [targetUser.id], - // { - // source: 'sharing', - // title: `${actor.user.username} shared items with you`, - // template: 'file-shared-with-you', - // fields: { - // username: actor.user.username, - // permissions: permissions.map( - // (p) => p.permission, - // ), - // }, - // }, - // ); - // } - // } - // recipientResults.push({ - // $: 'api:status-report', - // status: 'success', - // }); - // } else if (recipientStr.includes('@')) { - // // Email recipient — store pending share - // if (!dryRun) { - // const share = await this.stores.share.create({ - // issuerUserId: actor.user.id, - // recipientEmail: recipientStr.toLowerCase(), - // data: { - // permissions, - // metadata: body.metadata ?? {}, - // }, - // }); + /** + * POST /share/revoke — withdraw recipients' access to items. Same fan-out + * contract as POST /share: every (recipient, item) pair is its own revoke + * with its own outcome. Silently dropping pairs after the first would leave + * access standing that the caller believes is gone. + */ + @Post('/revoke', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIMIT, + }) + async revokeShare(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const body = this.#body(req); + const recipients = this.#recipients(body); + const items = this.#items(body, actor); + + const pairs = recipients.flatMap((recipient) => + items.map((item) => ({ recipient, item })), + ); + + const settled = await runWithConcurrencyLimitSettled( + pairs, + SHARE_CONCURRENCY, + ({ recipient, item }) => + this.services.share.unshare(actor, { ...item, recipient }), + ); + + let revoked = 0; + const results: ShareOutcome[] = settled.map((outcome, index) => { + const { recipient, item } = pairs[index]; + const label = recipient.email ?? recipient.username ?? ''; + if (outcome.status === 'fulfilled') { + revoked += outcome.value.revoked; + return { + recipient: label, + ...(item.path ? { path: item.path } : {}), + ...(item.uid ? { uid: item.uid } : {}), + status: 'success', + }; + } + return { + recipient: label, + ...(item.path ? { path: item.path } : {}), + ...(item.uid ? { uid: item.uid } : {}), + status: 'error', + ...this.#errorShape(outcome.reason), + }; + }); + + const succeeded = results.filter((r) => r.status === 'success').length; + res.json({ + status: + succeeded === results.length + ? 'success' + : succeeded > 0 + ? 'mixed' + : 'aborted', + revoked, + results, + }); + } - // // Sign a share token (14-day expiry) - // const token = this.services.token.sign( - // SHARE_TOKEN_TYPE, - // { - // type: `token:${SHARE_TOKEN_TYPE}`, - // uid: share.uid, - // }, - // { expiresIn: SHARE_TOKEN_EXPIRY }, - // ); + /** + * GET /share/shared-with-me — paginated listing of what others have shared + * with the caller. + */ + @Get('/shared-with-me', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listSharedWithMe(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const query = this.#query(req); + + const page = await this.services.share.listSharedWithMe(actor, { + limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }), + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + includeTotal: query.includeTotal === 'true', + }); + + res.json({ + items: await Promise.all( + page.items.map((share) => this.#toClientShare(share)), + ), + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(page.total !== undefined ? { total: page.total } : {}), + }); + } - // // Email the share link - // const origin = `https://${this.config.domain ?? 'puter.com'}`; - // try { - // await this.clients.email.sendRaw({ - // to: recipientStr, - // subject: `${actor.user.username} shared something with you on Puter`, - // html: `

${actor.user.username} shared items with you.

Click here to accept

`, - // }); - // } catch (err) { - // console.warn('[share] email send failed', err); - // } - // } - // recipientResults.push({ - // $: 'api:status-report', - // status: 'success', - // }); - // } else { - // recipientResults.push({ - // $: 'error', - // message: 'User not found', - // }); - // } - // } catch (err) { - // recipientResults.push({ $: 'error', message: String(err) }); - // } - // } + /** GET /share/shares — who can reach one item. */ + @Get('/shares', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listSharesOf(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const query = this.#query(req); + const target: ShareTarget = {}; + if (typeof query.uid === 'string') target.uid = query.uid; + if (typeof query.path === 'string') + target.path = expandTildePath(query.path, actor.user?.username); + if (!target.uid && !target.path) { + throw new HttpError(400, 'one of `uid` or `path` is required', { + legacyCode: 'bad_request', + }); + } + + const shares = await this.services.share.listSharesOf(actor, target); + res.json({ + items: await Promise.all( + shares.map((share) => this.#toClientShare(share)), + ), + }); + } - // const allOk = recipientResults.every( - // (r: unknown) => (r as Record).status === 'success', - // ); - // const anyOk = recipientResults.some( - // (r: unknown) => (r as Record).status === 'success', - // ); + // -- Helpers ------------------------------------------------------ + + /** + * Only ever the username — never the internal id, and never an email the + * caller didn't already supply. + * + * `thumbnail` is stored as an `s3://bucket/key` URI, so it is swapped for a + * signed URL rather than emitted: the raw value names internal storage and + * no client can render it. + */ + async #toClientShare(share: ResolvedShare) { + const thumbnail = + share.thumbnail === undefined + ? undefined + : await signEntryThumbnail( + this.clients.event, + share.entryUid, + share.thumbnail, + ); + return { + uid: share.uid, + mode: share.mode, + path: share.path, + // A share listing has no fsentry behind it for a client to stat. + ...(share.name === undefined ? {} : { name: share.name }), + ...(share.type === undefined ? {} : { type: share.type }), + ...(thumbnail === undefined ? {} : { thumbnail }), + ...(share.owner === undefined + ? {} + : { owner: share.owner.username }), + uid_entry: share.entryUid, + is_dir: share.isDir, + issuer: share.issuer.username, + holder: share.holder.username, + created_at: share.createdAt, + issued_by_app: share.issuedByApp ?? null, + inherited_from: share.inheritedFrom ?? null, + modified: share.modified, + size: share.size, + }; + } - // res.json({ - // $: 'api:share', - // $version: 'v0.0.0', - // status: allOk ? 'success' : anyOk ? 'mixed' : 'aborted', - // recipients: recipientResults, - // ...(dryRun ? { dry_run: true } : {}), - // }); - // }; + #requireActor(req: Request): Actor { + const actor = req.actor; + if (!actor?.user) + throw new HttpError(401, 'Unauthorized', { + legacyCode: 'unauthorized', + }); + return actor; + } - // // -- Helpers ------------------------------------------------------ + #body(req: Request): Record { + const body = req.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new HttpError(400, 'body must be an object', { + legacyCode: 'bad_request', + }); + } + return body as Record; + } - // /** - // * Convert share declarations into a flat permission list. - // * Supports `fs-share` ({ path, access }) and `app-share` ({ uid, name }). - // */ - // #resolvePermissions( - // shares: unknown[], - // ): Array<{ permission: string; extra?: Record }> { - // const perms: Array<{ - // permission: string; - // extra?: Record; - // }> = []; + #query(req: Request): Record { + return (req.query ?? {}) as Record; + } - // for (const share of shares) { - // if (!share || typeof share !== 'object') continue; - // const s = share as Record; + #recipients(body: Record): ShareRecipient[] { + const raw = body.recipients ?? body.recipient; + const list = Array.isArray(raw) ? raw : [raw]; + const out: ShareRecipient[] = []; + for (const entry of list) { + if (typeof entry === 'string') { + const value = entry.trim(); + if (!value) continue; + out.push( + value.includes('@') + ? { email: value } + : { username: value }, + ); + continue; + } + if (entry && typeof entry === 'object') { + const rec = entry as Record; + const email = + typeof rec.email === 'string' ? rec.email.trim() : ''; + const username = + typeof rec.username === 'string' ? rec.username.trim() : ''; + if (email || username) { + out.push(email ? { email } : { username }); + } + } + } + if (out.length === 0) { + throw new HttpError(400, '`recipients` is required', { + legacyCode: 'bad_request', + }); + } + const max = this.config.share_max_recipients ?? DEFAULT_MAX_RECIPIENTS; + if (out.length > max) { + throw new HttpError(400, `at most ${max} recipients per request`, { + legacyCode: 'too_many_recipients', + }); + } + return out; + } - // if (s.$ === 'fs-share' || s.type === 'fs-share' || s.path) { - // const path = String(s.path ?? ''); - // const access = String(s.access ?? 'read'); - // if (path) { - // perms.push({ permission: `fs:${path}:${access}` }); - // } - // } else if ( - // s.$ === 'app-share' || - // s.type === 'app-share' || - // s.uid || - // s.name - // ) { - // const appUid = String(s.uid ?? s.name ?? ''); - // if (appUid) { - // perms.push({ permission: `app:uid#${appUid}:access` }); - // } - // } - // } + #items(body: Record, actor: Actor): ShareTarget[] { + const username = actor.user?.username; + const raw = body.items ?? body.item ?? body.path ?? body.uid; + const list = Array.isArray(raw) ? raw : [raw]; + const out: ShareTarget[] = []; + for (const entry of list) { + if (typeof entry === 'string') { + const value = entry.trim(); + if (!value) continue; + // Tilde-rooted strings are paths, as the legacy FS routes treat them. + const isPath = value.startsWith('/') || value.startsWith('~'); + out.push( + isPath + ? { path: expandTildePath(value, username) } + : { uid: value }, + ); + continue; + } + if (entry && typeof entry === 'object') { + const item = entry as Record; + const path = + typeof item.path === 'string' + ? expandTildePath(item.path, username) + : ''; + const uid = typeof item.uid === 'string' ? item.uid : ''; + if (path || uid) out.push(path ? { path } : { uid }); + } + } + if (out.length === 0) { + throw new HttpError(400, '`items` is required', { + legacyCode: 'bad_request', + }); + } + const max = this.config.share_max_items ?? DEFAULT_MAX_ITEMS; + if (out.length > max) { + throw new HttpError(400, `at most ${max} items per request`, { + legacyCode: 'too_many_items', + }); + } + return out; + } - // return perms; - // } + /** + * Report a failure without widening what the caller already knew. The + * service already decides 404-vs-403; anything unrecognized becomes a + * generic error rather than leaking an internal message. + */ + #errorShape(reason: unknown): { message: string; code?: string } { + if (!isHttpError(reason) || reason.statusCode >= 500) { + return { message: 'Request failed' }; + } + const code = reason.legacyCode ?? reason.code; + return { + message: reason.message || 'Request failed', + ...(code ? { code } : {}), + }; + } } diff --git a/src/backend/controllers/webdav/WebDAVController.test.ts b/src/backend/controllers/webdav/WebDAVController.test.ts index 6c3235022b..dc13060620 100644 --- a/src/backend/controllers/webdav/WebDAVController.test.ts +++ b/src/backend/controllers/webdav/WebDAVController.test.ts @@ -8,6 +8,7 @@ import { hash as bcryptHash } from 'bcrypt'; import { PuterRouter } from '../../core/http/PuterRouter.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; +import { runWithContext } from '../../core/context.js'; import { generateDefaultFsentries } from '../../util/userProvisioning.js'; import type { WebDAVController } from './WebDAVController.js'; @@ -1166,6 +1167,68 @@ describe('WebDAVController verbs', () => { 'text/markdown', ); }); + + it("answers a share root's parent with a virtual collection", async () => { + const owner = await makeUser(); + const holder = await makeUser(); + await dispatch({ + method: 'MKCOL', + path: `/${owner.username}/Documents/Album`, + actor: owner.actor, + }); + const root = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Documents/Album`, + ))!; + await server.services.acl.setUserUser( + owner.actor as never, + holder.actor as never, + { + path: root.path, + resolveAncestors: () => + server.services.fs.getAncestorChain(root.path), + }, + 'read', + ); + + // `//` is where Up from a share root lands; it is not + // a real path, so it answers as a virtual collection whose only + // member is the share root at its masked path. + const captured = await dispatch({ + method: 'PROPFIND', + path: `/${owner.username}/${root.uuid}`, + actor: holder.actor, + }); + expect(captured.statusCode).toBe(207); + const xml = captured.body as string; + expect(xml).toContain( + `/${owner.username}/${root.uuid}/`, + ); + expect(xml).toContain( + `/${owner.username}/${root.uuid}/Album/`, + ); + // The owner's real tree never shows through. + expect(xml).not.toContain('/Documents/'); + }); + + it("keeps a share root's parent a 404 for a caller with no share", async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + await dispatch({ + method: 'MKCOL', + path: `/${owner.username}/Documents/Private`, + actor: owner.actor, + }); + const root = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Documents/Private`, + ))!; + + const captured = await dispatch({ + method: 'PROPFIND', + path: `/${owner.username}/${root.uuid}`, + actor: stranger.actor, + }); + expect(captured.statusCode).toBe(404); + }); }); describe('PROPPATCH', () => { @@ -1653,4 +1716,62 @@ describe('WebDAVController verbs', () => { expect(captured.body).toBe('Lock token does not match this path'); }); }); + + describe('shared-path masking', () => { + // The DAV controller authenticates in-controller, after the request + // context snapshotted an empty `req.actor` — so it has to place the + // actor into the context itself or outbound masking silently turns + // off and PROPFIND lists the owner's real paths. + it('lists a shared folder under its masked path, not the owner’s real one', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + + const dirUuid = uuidv4(); + const dirPath = `/${owner.username}/Documents/Secrets`; + const now = Math.floor(Date.now() / 1000); + const parent = (await server.stores.fsEntry.getEntryByPath( + `/${owner.username}/Documents`, + ))!; + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 1, ?, ?, ?)', + [dirUuid, 'Secrets', dirPath, owner.userId, now, parent.id, parent.uuid], + ); + const dir = (await server.stores.fsEntry.getEntryByPath(dirPath))!; + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)', + [uuidv4(), 'plan.txt', `${dirPath}/plan.txt`, owner.userId, now, dir.id, dir.uuid], + ); + + await runWithContext({ actor: owner.actor as never }, () => + server.services.share.share(owner.actor as never, { + uid: dirUuid, + recipient: { username: recipient.username! }, + mode: 'read', + }), + ); + + const masked = `/${owner.username}/${dirUuid}/Secrets`; + const { res, captured } = makeRes(); + // The same ALS wrap every real request gets from the + // request-context middleware, with the pre-auth (empty) actor. + await runWithContext({ actor: undefined }, () => + dispatchMiddleware( + makeReq({ + method: 'PROPFIND', + path: masked, + headers: { depth: '1' }, + actor: recipient.actor, + }), + res, + noop, + ), + ); + + expect(captured.statusCode).toBe(207); + const xml = String(captured.body); + expect(xml).toContain(`${masked}/plan.txt`); + expect(xml).not.toContain('/Documents/Secrets'); + }); + }); + }); diff --git a/src/backend/controllers/webdav/WebDAVController.ts b/src/backend/controllers/webdav/WebDAVController.ts index 1168b795b5..34af181717 100644 --- a/src/backend/controllers/webdav/WebDAVController.ts +++ b/src/backend/controllers/webdav/WebDAVController.ts @@ -30,6 +30,12 @@ import { import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { verify as verifyOtp } from '../../services/auth/OTPUtil.js'; import { expandTildePath } from '../../services/fs/resolveNode.js'; +import { + maskEntryPath, + parseMaskedSharePath, + resolveSharePath, +} from '../../services/fs/sharePathMask.js'; +import { Context } from '../../core/context.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import { toLegacyEntry } from '../fs/legacyFsHelpers.js'; import { PuterController } from '../types.js'; @@ -160,6 +166,12 @@ export class WebDAVController extends PuterController { assertNotSuspended(actor.user); assertVerifiedAccount(actor.user); + // DAV authenticates here rather than in the auth probe, so the actor + // was absent when the request context snapshotted `req.actor`. Set it + // now: shared-path masking (and anything else downstream that asks the + // context who is acting) is blind without it. + if (Context.current()) Context.set('actor', actor); + // And the same budget gate the FS routes declare with // `requireCredits`, for the verbs that move content — DAV serves the // same files over a metered host, so leaving it out would make mounting @@ -176,9 +188,10 @@ export class WebDAVController extends PuterController { // Expand `~`/`~/...` against the authenticated actor's username. // WebDAV doesn't standardize `~`, but some clients do — and the // pre-existing behaviour silently expanded it via the FS store. - const davPath = expandTildePath( - decodeURIComponent(req.path), - actor.user.username, + const davPath = await resolveSharePath( + this.stores.fsEntry, + actor, + expandTildePath(decodeURIComponent(req.path), actor.user.username), ); const redis = this.clients.redis; const lockToken = extractLockToken( @@ -408,13 +421,36 @@ export class WebDAVController extends PuterController { davPath === '/' ? null // root always exists : await this.stores.fsEntry.getEntryByPath(davPath); - if (davPath !== '/' && !entry) + if (davPath !== '/' && !entry) { + // `//` — the parent of a share root — is not a real + // path: the uuid stands in for the owner's folder, which the + // recipient cannot see. A client walking up from a share (or down + // toward one, as the Windows redirector does segment by segment) + // lands here, so answer with a virtual collection whose only + // member is the share root, rather than a dead end. + const virtual = await this.#shareRootParentPropfind( + actor, + davPath, + depth, + ); + if (virtual) { + res.status(207) + .set({ 'Content-Type': 'application/xml; charset=utf-8' }) + .send(wrapMultistatus(virtual.join('\n'))); + return; + } throw new HttpError(404, 'Not Found', { legacyCode: 'not_found' }); + } await this.#assertRead(actor, davPath); const isDir = davPath === '/' || !!entry?.isDir; - const responses = [propfindEntry(davPath, entry, isDir)]; + // `davPath` is the resolved real path; the href has to be the masked + // one, like every child below it, or the self-entry names the owner's + // real folder. + const responses = [ + propfindEntry(entry ? maskEntryPath(entry) : davPath, entry, isDir), + ]; if (depth !== '0' && isDir && entry) { const children = await this.services.fs.listDirectory( @@ -422,7 +458,9 @@ export class WebDAVController extends PuterController { {}, ); for (const child of children) { - responses.push(propfindEntry(child.path, child, child.isDir)); + responses.push( + propfindEntry(maskEntryPath(child), child, child.isDir), + ); } } else if (depth !== '0' && davPath === '/') { // Root: list top-level user directories @@ -431,7 +469,11 @@ export class WebDAVController extends PuterController { ); if (rootEntry) { responses.push( - propfindEntry(rootEntry.path, rootEntry, rootEntry.isDir), + propfindEntry( + maskEntryPath(rootEntry), + rootEntry, + rootEntry.isDir, + ), ); } } @@ -631,7 +673,7 @@ export class WebDAVController extends PuterController { redis: unknown, lockToken: string | null, ): Promise { - const destPath = this.#parseDestination(req); + const destPath = await this.#parseDestination(req); if ( !(await hasWritePermission( redis as import('ioredis').Cluster, @@ -689,7 +731,7 @@ export class WebDAVController extends PuterController { redis: unknown, lockToken: string | null, ): Promise { - const destPath = this.#parseDestination(req); + const destPath = await this.#parseDestination(req); const r = redis as import('ioredis').Cluster; if (!(await hasWritePermission(r, davPath, lockToken))) throw new HttpError(423, 'Locked', { legacyCode: 'conflict' }); @@ -833,6 +875,47 @@ export class WebDAVController extends PuterController { res.status(204).end(); } + /** + * PROPFIND responses for the virtual collection at `//`, or + * null when `davPath` isn't that shape, the uuid doesn't resolve to an + * entry of `owner`'s, or the actor cannot read the share — the caller falls + * through to 404 in every null case, so an unauthorized probe learns + * nothing about whether the uuid exists. + */ + async #shareRootParentPropfind( + actor: Actor, + davPath: string, + depth: string | string[], + ): Promise { + const parsed = parseMaskedSharePath(davPath); + if (!parsed || parsed.tail !== '') return null; + if (parsed.ownerUsername === actor.user?.username) return null; + + const root = await this.stores.fsEntry.getEntryByUuid(parsed.rootUuid); + if (!root) return null; + // Same guard as resolveSharePath: the mask names the owner, so the + // uuid must be theirs. + if (root.path.split('/')[1] !== parsed.ownerUsername) return null; + + const allowed = await this.services.acl.check( + actor, + { + path: root.path, + resolveAncestors: () => + this.services.fs.getAncestorChain(root.path), + }, + 'read', + ); + if (!allowed) return null; + + const maskedRoot = `/${parsed.ownerUsername}/${root.uuid}/${root.name}`; + const responses = [propfindEntry(davPath, null, true)]; + if (depth !== '0') { + responses.push(propfindEntry(maskedRoot, root, root.isDir)); + } + return responses; + } + // -- ACL helpers ------------------------------------------------- async #assertRead(actor: Actor, path: string): Promise { @@ -890,18 +973,22 @@ export class WebDAVController extends PuterController { // -- Misc helpers ------------------------------------------------ - #parseDestination(req: Request): string { + async #parseDestination(req: Request): Promise { const dest = req.headers.destination as string | undefined; if (!dest) throw new HttpError(400, 'Missing Destination header', { legacyCode: 'bad_request', }); + let raw: string; try { const url = new URL(dest, `http://${req.headers.host}`); - return decodeURIComponent(url.pathname); + raw = decodeURIComponent(url.pathname); } catch { - return decodeURIComponent(dest); + raw = decodeURIComponent(dest); } + // The destination is addressed the same way as the request path, so a + // client that browsed into a share names its target the same way too. + return resolveSharePath(this.stores.fsEntry, Context.get('actor'), raw); } } diff --git a/src/backend/services/acl/ACLService.test.ts b/src/backend/services/acl/ACLService.test.ts index f1e42e022e..0b9007c429 100644 --- a/src/backend/services/acl/ACLService.test.ts +++ b/src/backend/services/acl/ACLService.test.ts @@ -522,6 +522,39 @@ describe('ACLService.check — stronger modes imply weaker ones', () => { ]); }); + it.each(['see', 'list', 'read', 'write'] as const)( + 'answers %s from a manage grant on the same node', + async (mode) => { + const { service, services } = makeService(); + services.permission.scan.mockImplementation( + async (_actor: unknown, permissions: string[]) => + permissions.includes('manage:fs:uid\\C/other/f') + ? [{ $: 'option', key: 'k' }] + : [], + ); + expect( + await service.check(issuerActor, resource('/other/f'), mode), + ).toBe(true); + }, + ); + + it('answers write from a manage grant on an ancestor directory', async () => { + const { service, services } = makeService(); + services.permission.scan.mockImplementation( + async (_actor: unknown, permissions: string[]) => + permissions.includes('manage:fs:uid\\C/other') + ? [{ $: 'option', key: 'k' }] + : [], + ); + expect( + await service.check( + issuerActor, + resource('/other/deep/file.txt'), + 'write', + ), + ).toBe(true); + }); + it('inherits access granted on an ancestor directory', async () => { const { service, services } = makeService(); services.permission.scan.mockImplementation( @@ -569,6 +602,21 @@ describe('ACLService.check — scoped tokens and manage', () => { ).toBe(true); }); + it('accepts a manage grant recorded against the token for a write', async () => { + const { service, stores } = makeService(); + stores.permission.hasAccessTokenPerm.mockImplementation( + async (_uid: string, permission: string) => + permission === 'manage:fs:uid\\C/issuer/projects', + ); + expect( + await service.check( + scopedTokenActor(), + resource('/issuer/projects'), + 'write', + ), + ).toBe(true); + }); + it('accepts an ancestor grant recorded against the token', async () => { const { service, stores } = makeService(); stores.permission.hasAccessTokenPerm.mockImplementation( @@ -813,6 +861,153 @@ describe('ACLService.statUserUser / setUserUser (integration)', () => { ).toBe(false); }); + describe('app-under-user on shared paths', () => { + const makeApp = async (ownerUserId: number) => + ( + server.stores.app.create as unknown as ( + fields: Record, + opts: { ownerUserId: number }, + ) => Promise<{ uid: string; id: number }> + )( + { + name: `acl-app-${uuidv4()}`, + title: 'ACL app', + index_url: `https://acl-${uuidv4()}.test/`, + }, + { ownerUserId }, + ); + + const asApp = (user: Actor, app: { uid: string; id: number }): Actor => ({ + user: user.user, + app: { uid: app.uid, id: app.id }, + }); + + it('does not hand an app its user’s shared access', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const res = await ownedResource(issuer); + const app = await makeApp(holder.user.id!); + + await runWithContext({ actor: issuer }, () => + acl.setUserUser(issuer, holder, res, 'read'), + ); + + expect(await acl.check(holder, res, 'read')).toBe(true); + expect(await acl.check(asApp(holder, app), res, 'read')).toBe(false); + }); + + it('bounds an app by its user even with its own grant', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const res = await ownedResource(issuer); + const app = await makeApp(holder.user.id!); + const appActorForHolder = asApp(holder, app); + + await runWithContext({ actor: issuer }, () => + acl.setUserUser(issuer, holder, res, 'read'), + ); + await runWithContext({ actor: holder }, () => + server.services.permission.grantUserAppPermission( + holder, + app.uid, + `fs:${res.uid}:read`, + ), + ); + expect(await acl.check(appActorForHolder, res, 'read')).toBe(true); + + await runWithContext({ actor: issuer }, () => + server.services.permission.revokeUserUserPermission( + issuer, + holder.user.username!, + `fs:${res.uid}:read`, + ), + ); + + // The app grant survives, but the user no longer has the file, so + // the app cannot outlive its user's access. + expect(await acl.check(appActorForHolder, res, 'read')).toBe(false); + }); + + it('follows a moved node and does not spread to its siblings', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const res = await ownedResource(issuer); + const sibling = await ownedResource(issuer, 'Pictures'); + + await runWithContext({ actor: issuer }, () => + acl.setUserUser(issuer, holder, res, 'read'), + ); + + const moved: ResourceDescriptor = { + path: `/${issuer.user.username}/Renamed`, + resolveAncestors: async () => [ + { uid: res.uid, path: `/${issuer.user.username}/Renamed` }, + ], + }; + + // Grants key on the uuid, so a rename carries access with it and + // cannot leak onto a neighbour that merely took the old name. + expect(await acl.check(holder, moved, 'read')).toBe(true); + expect(await acl.check(holder, sibling, 'read')).toBe(false); + }); + + it('keeps a shared AppData directory to the app it belongs to', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const mine = await makeApp(holder.user.id!); + const theirs = await makeApp(holder.user.id!); + const home = `/${issuer.user.username}`; + const homeEntry = + await server.stores.fsEntry.getEntryByPath(home); + + const appDataOf = (appUid: string): ResourceDescriptor => ({ + path: `${home}/AppData/${appUid}`, + resolveAncestors: async () => [ + { uid: `virtual-${appUid}`, path: `${home}/AppData/${appUid}` }, + { uid: String(homeEntry!.uuid), path: home }, + ], + }); + + await runWithContext({ actor: issuer }, () => + acl.setUserUser( + issuer, + holder, + { path: home, resolveAncestors: async () => [ + { uid: String(homeEntry!.uuid), path: home }, + ] }, + 'write', + ), + ); + + expect( + await acl.check(asApp(holder, mine), appDataOf(mine.uid), 'write'), + ).toBe(true); + expect( + await acl.check(asApp(holder, mine), appDataOf(theirs.uid), 'write'), + ).toBe(false); + }); + }); + + it('leaves one mode standing when two writes race on the same node', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const res = await ownedResource(issuer); + + // Unserialized, each call acts on the same empty snapshot and both + // grants survive. + await Promise.all([ + runWithContext({ actor: issuer }, () => + acl.setUserUser(issuer, holder, res, 'read'), + ), + runWithContext({ actor: issuer }, () => + acl.setUserUser(issuer, holder, res, 'write'), + ), + ]); + + const stat = await acl.statUserUser(issuer, holder, res); + expect(stat[res.path] ?? []).toHaveLength(1); + }); + it('downgrading a manage share to read revokes the manage grant', async () => { const issuer = await makeUser(); const holder = await makeUser(); diff --git a/src/backend/services/acl/ACLService.ts b/src/backend/services/acl/ACLService.ts index 58d5fba405..d78401d49f 100644 --- a/src/backend/services/acl/ACLService.ts +++ b/src/backend/services/acl/ACLService.ts @@ -73,6 +73,11 @@ const PUBLIC_READ_MODES: ReadonlyArray = Object.freeze([ 'see', ]); +/** Lock bounds for `setUserUser`; past the retry budget a 409 beats waiting. */ +const SET_USER_LOCK_TTL_SECONDS = 5; +const SET_USER_LOCK_RETRY_MS = 40; +const SET_USER_LOCK_ATTEMPTS = 25; + // -- ACLService ------------------------------------------------------- /** @@ -175,19 +180,10 @@ export class ACLService extends PuterService { if (actor.accessToken.fullAccess) return true; for (const ancestor of ancestors) { - const permissions = - mode === MANAGE_PERM_PREFIX - ? [ - PermissionUtil.join( - MANAGE_PERM_PREFIX, - 'fs', - ancestor.uid, - ), - ] - : MODES_ABOVE[mode].map((m) => - PermissionUtil.join('fs', ancestor.uid, m), - ); - for (const permission of permissions) { + for (const permission of this.#permissionsFor( + ancestor.uid, + mode, + )) { if ( await this.stores.permission.hasAccessTokenPerm( actor.accessToken.uid, @@ -222,21 +218,9 @@ export class ACLService extends PuterService { // Widen the scan to all "higher" modes (`write` covers `read`/`list`/ // `see`, etc.) so granting a stronger mode implies the weaker ones. for (const ancestor of ancestors) { - const permissions = - mode === MANAGE_PERM_PREFIX - ? [ - PermissionUtil.join( - MANAGE_PERM_PREFIX, - 'fs', - ancestor.uid, - ), - ] - : MODES_ABOVE[mode].map((m) => - PermissionUtil.join('fs', ancestor.uid, m), - ); const reading = await this.services.permission.scan( actor, - permissions, + this.#permissionsFor(ancestor.uid, mode), ); const options = PermissionUtil.readingToOptions(reading); if (options.length > 0) return true; @@ -245,6 +229,19 @@ export class ACLService extends PuterService { return false; } + /** + * Permissions on `uid` that satisfy `mode`. `manage` sits above the whole + * family — it answers any mode, but nothing answers it. + */ + #permissionsFor(uid: string, mode: AclMode): string[] { + const manage = PermissionUtil.join(MANAGE_PERM_PREFIX, 'fs', uid); + if (mode === MANAGE_PERM_PREFIX) return [manage]; + return [ + ...MODES_ABOVE[mode].map((m) => PermissionUtil.join('fs', uid, m)), + manage, + ]; + } + /** * When a check fails, return a user-safe error: 404 if the actor can't even * `see` the resource (don't leak existence), 403 otherwise. @@ -345,6 +342,47 @@ export class ACLService extends PuterService { legacyCode: 'bad_request', }); + // Resolved up front so the whole read-modify-write runs under one + // lock. Descriptors cache the chain, so statUserUser reuses this. + const ancestors = await resource.resolveAncestors(); + const self = ancestors[0]; + if (!self) + throw new HttpError( + 400, + 'resource has no ancestor chain (is it root?)', + { legacyCode: 'bad_request' }, + ); + + const username = holder.user.username; + return this.#withNodeLock( + `${issuer.user.id}:${holder.user.id}:${self.uid}`, + () => + this.#setUserUserLocked( + issuer, + holder, + resource, + mode, + self.uid, + username, + options, + ), + ); + } + + /** + * Body of {@link setUserUser}. Read-modify-write, so it only runs under the + * lock above: concurrent calls would each act on their own snapshot and + * both grants would survive. + */ + async #setUserUserLocked( + issuer: Actor, + holder: Actor, + resource: ResourceDescriptor, + mode: AclMode, + uid: string, + username: string, + options: { onlyIfHigher?: boolean } = {}, + ): Promise { const stat = await this.statUserUser(issuer, holder, resource); const existing = stat[resource.path] ?? []; @@ -369,25 +407,13 @@ export class ACLService extends PuterService { } } - // Resolve the resource's own uid — first element of the ancestor - // chain is the resource itself (see ResourceDescriptor docstring). - const ancestors = await resource.resolveAncestors(); - const self = ancestors[0]; - if (!self) - throw new HttpError( - 400, - 'resource has no ancestor chain (is it root?)', - { legacyCode: 'bad_request' }, - ); - const uid = self.uid; - const newPerm = mode === MANAGE_PERM_PREFIX ? PermissionUtil.join(MANAGE_PERM_PREFIX, 'fs', uid) : PermissionUtil.join('fs', uid, mode); await this.services.permission.grantUserUserPermission( issuer, - holder.user.username, + username, newPerm, ); @@ -400,13 +426,65 @@ export class ACLService extends PuterService { if (existingMode === mode) continue; await this.services.permission.revokeUserUserPermission( issuer, - holder.user.username, + username, perm, ); } return true; } + /** + * Serialize writes to one (issuer, holder, node) triple. Fails open on a + * Redis error — a narrow race beats taking sharing down with the cache. + */ + async #withNodeLock(suffix: string, fn: () => Promise): Promise { + const key = `acl:set-user-user:${suffix}`; + const token = `${process.pid}:${Date.now()}:${Math.random()}`; + let held = false; + + try { + for (let attempt = 0; attempt < SET_USER_LOCK_ATTEMPTS; attempt++) { + const claimed = await this.clients.redis.set( + key, + token, + 'EX', + SET_USER_LOCK_TTL_SECONDS, + 'NX', + ); + if (claimed === 'OK') { + held = true; + break; + } + await new Promise((resolve) => + setTimeout(resolve, SET_USER_LOCK_RETRY_MS), + ); + } + if (!held) { + throw new HttpError( + 409, + 'another change to this share is in progress', + { legacyCode: 'conflict' }, + ); + } + } catch (err) { + if (err instanceof HttpError) throw err; + // Redis unavailable — proceed unserialized rather than fail. + return fn(); + } + + try { + return await fn(); + } finally { + try { + // Only clear our own claim — a lapsed TTL may have reassigned it. + const current = await this.clients.redis.get(key); + if (current === token) await this.clients.redis.del(key); + } catch { + // The TTL cleans up regardless. + } + } + } + /** * The highest mode currently in the ACL hierarchy. Callers that gate on * "top-level" access (e.g., share-everything) should use this instead of diff --git a/src/backend/services/cache/CacheReplicationService.test.ts b/src/backend/services/cache/CacheReplicationService.test.ts new file mode 100644 index 0000000000..814e001cf9 --- /dev/null +++ b/src/backend/services/cache/CacheReplicationService.test.ts @@ -0,0 +1,84 @@ +/* + * 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 . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; + +describe('CacheReplicationService', () => { + let server: PuterServer; + + beforeAll(async () => { + server = await setupTestServer(); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + const emitRemote = (cacheKey: unknown) => + server.clients.event.emitAndWait( + 'outer.cacheUpdate', + { cacheKey } as { cacheKey: string[] }, + { from_outside: true }, + ); + + it('drops keys a peer region invalidated', async () => { + const key = `cacherepl-${uuidv4()}`; + await server.clients.redis.set(key, 'stale'); + + await emitRemote([key]); + + expect(await server.clients.redis.get(key)).toBeNull(); + }); + + it('ignores a locally-emitted update, which already applied itself', async () => { + const key = `cacherepl-${uuidv4()}`; + await server.clients.redis.set(key, 'fresh'); + + await server.clients.event.emitAndWait( + 'outer.cacheUpdate', + { cacheKey: [key] }, + {}, + ); + + expect(await server.clients.redis.get(key)).toBe('fresh'); + }); + + it('deletes rather than adopting the sender payload', async () => { + const key = `cacherepl-${uuidv4()}`; + await server.clients.redis.set(key, 'ours'); + + await server.clients.event.emitAndWait( + 'outer.cacheUpdate', + { cacheKey: [key], data: 'theirs', ttlSeconds: 60 } as never, + { from_outside: true }, + ); + + // Their value came from their own replica; force a local re-read. + expect(await server.clients.redis.get(key)).toBeNull(); + }); + + it('survives a malformed payload', async () => { + await expect(emitRemote('not-an-array')).resolves.not.toThrow(); + await expect(emitRemote([123, '', null])).resolves.not.toThrow(); + await expect(emitRemote(undefined)).resolves.not.toThrow(); + }); +}); diff --git a/src/backend/services/cache/CacheReplicationService.ts b/src/backend/services/cache/CacheReplicationService.ts new file mode 100644 index 0000000000..b821ea7e0a --- /dev/null +++ b/src/backend/services/cache/CacheReplicationService.ts @@ -0,0 +1,60 @@ +/* + * 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 . + */ + +import { PuterService } from '../types'; + +/** + * Applies `outer.cacheUpdate` from peer regions. + * + * `PuterStore.publishCacheKeys({ broadcast: true })` writes its own cluster's + * Redis and emits the same mutation for peers; `BroadcastService` ships it over + * a webhook. Nothing consumed it on the far side, so cross-region cache + * replication silently no-oped. + * + * Always deletes, never re-writes the sender's payload: their value was derived + * from their own replica, so forcing a re-read here is the conservative move. + */ +export class CacheReplicationService extends PuterService { + override onServerStart(): void { + this.clients.event.on('outer.cacheUpdate', (_key, data, meta) => { + if (!(meta as { from_outside?: boolean })?.from_outside) return; + const raw = (data as { cacheKey?: unknown })?.cacheKey; + if (!Array.isArray(raw)) return; + const keys = raw.filter( + (key): key is string => typeof key === 'string' && key !== '', + ); + if (keys.length === 0) return; + void this.#invalidate(keys); + }); + } + + // Pipelined rather than a multi-key DEL, which would CROSSSLOT on Valkey. + async #invalidate(keys: string[]): Promise { + try { + const pipeline = this.clients.redis.pipeline(); + for (const key of keys) pipeline.del(key); + await pipeline.exec(); + } catch { + console.warn( + '[CacheReplicationService] failed to apply remote cache update:', + keys, + ); + } + } +} diff --git a/src/backend/services/fs/FSService.test.ts b/src/backend/services/fs/FSService.test.ts index eef0a94f10..8c7326a015 100644 --- a/src/backend/services/fs/FSService.test.ts +++ b/src/backend/services/fs/FSService.test.ts @@ -19,6 +19,7 @@ */ import { Readable } from 'node:stream'; +import { CreateBucketCommand } from '@aws-sdk/client-s3'; import { v4 as uuidv4 } from 'uuid'; import { afterAll, @@ -495,6 +496,36 @@ describe('FSService overwrite and dedupe resolution', () => { expect(await readBack(second)).toBe('bbbbb'); }); + it('keeps an overwrite in the bucket the entry already lives in', async () => { + const first = await writeFile( + user, + `${user.home}/Documents/pinned.txt`, + 'first', + ); + // A row whose content lives in another region's bucket — e.g. the + // owner uploaded it through a server in that region. + await server.clients.s3 + .get('eu-central-1') + .send(new CreateBucketCommand({ Bucket: 'far-bucket' })); + await server.clients.db.write( + 'UPDATE fsentries SET bucket = ?, bucket_region = ? WHERE uuid = ?', + ['far-bucket', 'eu-central-1', first.uuid], + ); + + const second = await writeFile( + user, + `${user.home}/Documents/pinned.txt`, + 'second', + { overwrite: true }, + ); + + // Uploading to this server's bucket instead would repoint the row + // and strand the original object in the old bucket. + expect(second.bucket).toBe('far-bucket'); + expect(second.bucketRegion).toBe('eu-central-1'); + expect(await readBack(second)).toBe('second'); + }); + it('dedupes into an unused " (n)" name, skipping names already taken', async () => { await writeFile(user, `${user.home}/Documents/d.txt`, 'a'); await writeFile(user, `${user.home}/Documents/d (1).txt`, 'a'); @@ -628,6 +659,37 @@ describe('FSService storage allowance', () => { ).resolves.toMatchObject({ wasOverwrite: false }); }); + it("judges a write into another user's tree by that owner's allowance", async () => { + const owner = await quotaUser(8); + const writer = await quotaUser(1024); + + // The override comes off the ACTING user's row — a roomy writer must + // not raise a full owner's cap when writing into the owner's tree + // (e.g. through a shared folder). + const error = await caught(() => + limitedFs.write( + writer.userId, + { + fileMetadata: { + path: `${owner.home}/Documents/big.txt`, + size: 16, + contentType: 'text/plain', + }, + fileContent: 'x'.repeat(16), + }, + undefined, + 1024, + ), + ); + expect(error.statusCode).toBe(413); + expect(error.legacyCode).toBe('storage_limit_reached'); + + // The same override still applies to the writer's own tree. + await expect( + writer.write('big.txt', 'x'.repeat(16), 1024), + ).resolves.toMatchObject({ wasOverwrite: false }); + }); + it('writes past a full account when the caller waives the quota', async () => { const user = await quotaUser(64); await user.write('fills-it.txt', 'x'.repeat(64)); @@ -2111,7 +2173,7 @@ describe('FSService mkdir, touch, rename and shortcuts', () => { `${user.home}/Documents/before.txt`, 'x', ); - const renamed = await fs.rename(entry, 'after.txt'); + const renamed = await fs.rename(user.userId, entry, 'after.txt'); expect(renamed.name).toBe('after.txt'); expect(renamed.path).toBe(`${user.home}/Documents/after.txt`); @@ -2124,7 +2186,7 @@ describe('FSService mkdir, touch, rename and shortcuts', () => { }); await writeFile(user, `${user.home}/Documents/olddir/inner.txt`, 'x'); - await fs.rename(dir, 'newdir'); + await fs.rename(user.userId, dir, 'newdir'); expect( await entryAt(user, '/Documents/newdir/inner.txt'), @@ -2140,18 +2202,34 @@ describe('FSService mkdir, touch, rename and shortcuts', () => { ); await writeFile(user, `${user.home}/Documents/taken.txt`, 'x'); - expect((await caught(() => fs.rename(entry, 'a/b'))).message).toBe( + expect((await caught(() => fs.rename(user.userId, entry, 'a/b'))).message).toBe( 'Name cannot contain a slash', ); - expect((await caught(() => fs.rename(entry, ' '))).message).toBe( + expect((await caught(() => fs.rename(user.userId, entry, ' '))).message).toBe( 'Name cannot be empty', ); expect( - (await caught(() => fs.rename(entry, 'taken.txt'))).statusCode, + (await caught(() => fs.rename(user.userId, entry, 'taken.txt'))).statusCode, ).toBe(409); // Renaming to the current name is a no-op that returns the same row. - await expect(fs.rename(entry, 'ren.txt')).resolves.toBe(entry); + await expect(fs.rename(user.userId, entry, 'ren.txt')).resolves.toBe(entry); + }); + + it("refuses to rename another user's entry without write on it", async () => { + const other = await makeUser(); + const entry = await writeFile( + user, + `${user.home}/Documents/theirs.txt`, + 'x', + ); + + // Unlike remove/move, rename only needs `write` on the entry itself — + // but a caller holding nothing at all is still turned away. + await expect( + fs.rename(other.userId, entry, 'renamed.txt'), + ).rejects.toMatchObject({ statusCode: 403 }); + expect(await entryAt(user, '/Documents/theirs.txt')).not.toBeNull(); }); it('creates a shortcut, conflicts on a taken name and dedupes on request', async () => { @@ -2707,7 +2785,7 @@ describe('FSService copy', () => { expect(first.path).toBe(`${user.home}/Desktop/phantom.txt`); // Renaming the occupant frees the path... - await fs.rename(first, 'phantom-renamed.txt'); + await fs.rename(user.userId, first, 'phantom-renamed.txt'); // ...so an immediate re-copy must succeed. A stale path-cache entry // for the old name used to surface a phantom conflict here — and a @@ -2746,6 +2824,416 @@ describe('FSService copy', () => { }); }); +describe('FSService restructuring a shared tree', () => { + let owner: TestUser; + let holder: TestUser; + let shared: FSEntry; + + const shareWrite = (entry: FSEntry) => + server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: entry.path, + resolveAncestors: () => fs.getAncestorChain(entry.path), + }, + 'write', + ); + + const asHolder = (run: () => Promise): Promise => + runWithContext({ actor: holder.actor }, run); + + const stillThere = async (path: string) => + (await server.stores.fsEntry.getEntryByPath(path, { + skipCache: true, + })) !== null; + + beforeAll(async () => { + owner = await makeUser(); + holder = await makeUser(); + shared = await fs.mkdir(owner.userId, { + path: `${owner.home}/Documents/Contents`, + }); + await shareWrite(shared); + }); + + it('lets a recipient delete a file inside the shared folder', async () => { + const file = await writeFile(owner, `${shared.path}/gone.txt`, 'x'); + + await asHolder(() => fs.remove(holder.userId, { entry: file })); + + expect(await stillThere(file.path)).toBe(false); + }); + + it('lets a recipient delete a subfolder of the shared folder', async () => { + const sub = await fs.mkdir(owner.userId, { + path: `${shared.path}/sub`, + }); + await writeFile(owner, `${sub.path}/deep.txt`, 'x'); + + await asHolder(() => + fs.remove(holder.userId, { entry: sub, recursive: true }), + ); + + expect(await stillThere(sub.path)).toBe(false); + expect(await stillThere(`${sub.path}/deep.txt`)).toBe(false); + }); + + it('lets a recipient rename a file inside the shared folder', async () => { + const file = await writeFile(owner, `${shared.path}/before.txt`, 'x'); + + const renamed = await asHolder(() => + fs.rename(holder.userId, file, 'after.txt'), + ); + + expect(renamed.path).toBe(`${shared.path}/after.txt`); + expect(renamed.userId).toBe(owner.userId); + }); + + it('sends a recipient-trashed item to the owner’s trash, still owned by the owner', async () => { + const file = await writeFile(owner, `${shared.path}/trashed.txt`, 'x'); + const ownerTrash = (await server.stores.fsEntry.getEntryByPath( + `${owner.home}/Trash`, + ))!; + + const moved = await asHolder(() => + fs.move(holder.userId, { + source: file, + destinationParent: ownerTrash, + newName: file.uuid, + }), + ); + + expect(moved.path).toBe(`${owner.home}/Trash/${file.uuid}`); + expect(moved.userId).toBe(owner.userId); + expect(await stillThere(file.path)).toBe(false); + }); + + it('puts a trashed item beyond the recipient’s reach', async () => { + const file = await writeFile(owner, `${shared.path}/hidden.txt`, 'x'); + const ownerTrash = (await server.stores.fsEntry.getEntryByPath( + `${owner.home}/Trash`, + ))!; + + const moved = await asHolder(() => + fs.move(holder.userId, { + source: file, + destinationParent: ownerTrash, + newName: file.uuid, + }), + ); + + const reachable = await server.services.acl.check( + holder.actor, + { + path: moved.path, + resolveAncestors: () => fs.getAncestorChain(moved.path), + }, + 'see', + ); + expect(reachable).toBe(false); + }); + + it('refuses to let a recipient delete the shared folder itself', async () => { + const error = await caught(() => + asHolder(() => + fs.remove(holder.userId, { entry: shared, recursive: true }), + ), + ); + + expect(error.statusCode).toBe(403); + expect(error.legacyCode).toBe('forbidden'); + expect(await stillThere(shared.path)).toBe(true); + }); + + it('lets a recipient rename a subfolder inside the shared folder', async () => { + const sub = await fs.mkdir(owner.userId, { + path: `${shared.path}/sub-to-rename`, + }); + + const renamed = await asHolder(() => + fs.rename(holder.userId, sub, 'sub-renamed'), + ); + + expect(renamed.path).toBe(`${shared.path}/sub-renamed`); + expect(renamed.userId).toBe(owner.userId); + }); + + it('refuses to let a recipient rename the shared FOLDER itself', async () => { + // A folder's name is structure the owner's subtree hangs off — only + // a directly-shared FILE is renameable by its recipient. + const error = await caught(() => + asHolder(() => fs.rename(holder.userId, shared, 'Renamed')), + ); + + expect(error.statusCode).toBe(403); + expect(await stillThere(shared.path)).toBe(true); + }); + + it('lets a recipient rename a file shared directly with them', async () => { + const file = await writeFile(owner, `${owner.home}/direct.txt`, 'x'); + await shareWrite(file); + + const renamed = await asHolder(() => + fs.rename(holder.userId, file, 'mine.txt'), + ); + + expect(renamed.path).toBe(`${owner.home}/mine.txt`); + expect(renamed.userId).toBe(owner.userId); + }); + + it('refuses rename to a recipient who holds only read', async () => { + const file = await writeFile(owner, `${owner.home}/lookdonttouch.txt`, 'x'); + await server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: file.path, + resolveAncestors: () => fs.getAncestorChain(file.path), + }, + 'read', + ); + + const error = await caught(() => + asHolder(() => fs.rename(holder.userId, file, 'touched.txt')), + ); + + expect(error.statusCode).toBe(403); + expect(await stillThere(file.path)).toBe(true); + }); + + it('refuses a stranger with no share at all', async () => { + const stranger = await makeUser(); + const file = await writeFile(owner, `${shared.path}/private.txt`, 'x'); + + const error = await caught(() => + runWithContext({ actor: stranger.actor }, () => + fs.remove(stranger.userId, { entry: file }), + ), + ); + + expect(error.statusCode).toBe(403); + expect(await stillThere(file.path)).toBe(true); + }); +}); + +describe('FSService ownership in a shared tree', () => { + let owner: TestUser; + let holder: TestUser; + let shared: FSEntry; + + const asHolder = (run: () => Promise): Promise => + runWithContext({ actor: holder.actor }, run); + + beforeAll(async () => { + owner = await makeUser(); + holder = await makeUser(); + shared = await fs.mkdir(owner.userId, { + path: `${owner.home}/Documents/Team`, + }); + await server.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: shared.path, + resolveAncestors: () => fs.getAncestorChain(shared.path), + }, + 'write', + ); + }); + + it('gives a folder the recipient creates to the folder owner', async () => { + const created = await asHolder(() => + fs.mkdir(holder.userId, { path: `${shared.path}/from-holder` }), + ); + + expect(created.userId).toBe(owner.userId); + }); + + it('gives a file the recipient writes to the folder owner', async () => { + const created = await asHolder(() => + fs.write(holder.userId, { + fileMetadata: { + path: `${shared.path}/note.txt`, + size: 1, + contentType: 'text/plain', + }, + fileContent: 'x', + }), + ); + + expect(created.fsEntry.userId).toBe(owner.userId); + }); + + it('gives a file the recipient touches to the folder owner', async () => { + const created = await asHolder(() => + fs.touch(holder.userId, { path: `${shared.path}/touched.txt` }), + ); + + expect(created.userId).toBe(owner.userId); + }); + + it('gives intermediate directories to the folder owner', async () => { + await asHolder(() => + fs.write(holder.userId, { + fileMetadata: { + path: `${shared.path}/a/b/deep.txt`, + size: 1, + contentType: 'text/plain', + createMissingParents: true, + }, + fileContent: 'x', + }), + ); + + const a = await server.stores.fsEntry.getEntryByPath( + `${shared.path}/a`, + ); + const b = await server.stores.fsEntry.getEntryByPath( + `${shared.path}/a/b`, + ); + expect(a?.userId).toBe(owner.userId); + expect(b?.userId).toBe(owner.userId); + }); + + it('gives a copy the recipient makes to the folder owner', async () => { + const source = await writeFile( + holder, + `${holder.home}/Documents/mine.txt`, + 'x', + ); + + const copy = await asHolder(() => + fs.copy(holder.userId, { + source, + destinationParent: shared, + newName: 'copied.txt', + }), + ); + + expect(copy.userId).toBe(owner.userId); + // The original stays where it was, with its own owner. + expect( + (await server.stores.fsEntry.getEntryByPath(source.path))?.userId, + ).toBe(holder.userId); + }); + + it('hands over an entry the recipient moves in, subtree and all', async () => { + const dir = await fs.mkdir(holder.userId, { + path: `${holder.home}/Documents/handover`, + }); + await writeFile( + holder, + `${holder.home}/Documents/handover/inside.txt`, + 'x', + ); + + const moved = await asHolder(() => + fs.move(holder.userId, { source: dir, destinationParent: shared }), + ); + + expect(moved.userId).toBe(owner.userId); + const inside = await server.stores.fsEntry.getEntryByPath( + `${shared.path}/handover/inside.txt`, + { skipCache: true }, + ); + expect(inside?.userId).toBe(owner.userId); + }); + +}); + +describe('FSService storage allowance in a shared tree', () => { + let limitedServer: PuterServer; + let limitedFs: FSService; + + beforeAll(async () => { + limitedServer = await setupTestServer({ + is_storage_limited: true, + } as never); + limitedFs = limitedServer.services.fs as unknown as FSService; + }); + + afterAll(async () => { + await limitedServer?.shutdown(); + }); + + const quotaUser = async (freeStorage: number) => { + const username = `fsqs-${Math.random().toString(36).slice(2, 10)}`; + const created = await limitedServer.stores.user.create({ + username, + uuid: uuidv4(), + password: null, + email: `${username}@test.local`, + free_storage: freeStorage, + requires_email_confirmation: false, + }); + await generateDefaultFsentries( + limitedServer.clients.db, + limitedServer.stores.user, + created, + ); + const refreshed = (await limitedServer.stores.user.getById(created.id))!; + return { + userId: refreshed.id, + home: `/${username}`, + actor: { + user: { + id: refreshed.id, + uuid: refreshed.uuid, + username: refreshed.username, + email: refreshed.email ?? null, + email_confirmed: true, + } as Actor['user'], + } as Actor, + }; + }; + + it('charges the folder owner, so their limit stops the writer', async () => { + const owner = await quotaUser(64); + const holder = await quotaUser(10 * 1024 * 1024); + const shared = await limitedFs.mkdir(owner.userId, { + path: `${owner.home}/Documents/Tight`, + }); + await limitedServer.services.acl.setUserUser( + owner.actor, + holder.actor, + { + path: shared.path, + resolveAncestors: () => + limitedFs.getAncestorChain(shared.path), + }, + 'write', + ); + + const body = 'x'.repeat(4096); + const error = await caught(() => + runWithContext({ actor: holder.actor }, () => + limitedFs.write(holder.userId, { + fileMetadata: { + path: `${shared.path}/big.txt`, + size: body.length, + }, + fileContent: body, + }), + ), + ); + + expect(error.statusCode).toBe(413); + expect(error.legacyCode).toBe('storage_limit_reached'); + // The same write into the writer's own roomy home still goes through, + // so it was the owner's limit that stopped it, not a blanket refusal. + await runWithContext({ actor: holder.actor }, () => + limitedFs.write(holder.userId, { + fileMetadata: { + path: `${holder.home}/Documents/big.txt`, + size: body.length, + }, + fileContent: body, + }), + ); + }); +}); + describe('FSService access checks', () => { let owner: TestUser; let stranger: TestUser; @@ -2932,6 +3420,13 @@ describe('FSService permission rules', () => { ); expect(higher).not.toContain(`fs:${file.uuid}:read`); }); + + it('lets a manage grant answer a write on the narrowest mode', async () => { + const higher = await server.services.permission.getHigherPermissions( + `fs:${file.uuid}:write`, + ); + expect(higher).toContain(`manage:fs:${file.uuid}`); + }); }); // -- Cross-app AppData (app-data::fs:) ---------------------- @@ -3081,7 +3576,7 @@ describe('FSService — cross-app AppData access', () => { asCalendar(() => fs.remove(owner.userId, { entry: contactsFile })), ).rejects.toMatchObject({ statusCode: 403 }); await expect( - asCalendar(() => fs.rename(contactsFile, 'renamed.json')), + asCalendar(() => fs.rename(owner.userId, contactsFile, 'renamed.json')), ).rejects.toMatchObject({ statusCode: 403 }); const desktop = (await server.stores.fsEntry.getEntryByPath( @@ -3110,7 +3605,7 @@ describe('FSService — cross-app AppData access', () => { it('allows rename once the delete class is granted', async () => { await grant(appDataPermission(contacts.uid, 'fs', 'delete')); const renamed = await asCalendar(() => - fs.rename(contactsFile, 'renamed.json'), + fs.rename(owner.userId, contactsFile, 'renamed.json'), ); expect(renamed.name).toBe('renamed.json'); }); diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index 8ef917570e..ccc7062275 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -240,6 +240,69 @@ export class FSService extends PuterService { }, }); + // -- manage-inherits-from-ancestor ----------------------------- + // `manage` on a directory covers what is inside it, the way `fs:*` + // access already reaches descendants through the ancestor chain. + // Without this the two are asymmetric: someone trusted to manage a + // shared folder can re-share the folder itself but nothing in it, and + // cannot even see who has access to a file within it. + // + // The whole chain is answered in one pass: split the path into its + // ancestors, then look the corresponding grants up in bulk. Recursing a + // level at a time would re-enter the permission scan once per level. + const permissionStore = this.stores.permission; + permissions.registerImplicator({ + id: 'manage-inherits-from-ancestor', + shortcut: true, + matches: (permission: string): boolean => + permission.startsWith(`${MANAGE_PERM_PREFIX}:fs:`), + check: async ({ actor, permission }): Promise => { + // Apps are bounded by their user through a separate path; + // widening them here would let one outrun that bound. + if (actor.app || actor.accessToken) return undefined; + if (!actor.user?.id) return undefined; + + const stripped = permission.replaceAll( + `${MANAGE_PERM_PREFIX}:`, + '', + ); + const uid = PermissionUtil.split(stripped)[1]; + if (!uid) return undefined; + + const entry = await fsEntryStore.getEntryByUuid(uid); + // Owning the entry is `is-owner`'s answer to give, and it runs + // first — there is nothing to inherit on your own tree. + if (!entry || entry.userId === actor.user.id) return undefined; + + const ancestors = ( + await this.getAncestorChain(entry.path) + ).slice(1); + if (ancestors.length === 0) return undefined; + const wanted = ancestors.map((ancestor) => + PermissionUtil.join(MANAGE_PERM_PREFIX, 'fs', ancestor.uid), + ); + + // Read the stores rather than re-entering `scan`, which would + // cost one recursive scan per ancestor. A `manage:fs:*` grant + // reaches a user three ways — issued to them, group-issued, or + // sitting in the flat view — and each store answers the whole + // list at once. + const [linked, group, flat] = await Promise.all([ + permissionStore.readLinkedUserUserPerms( + actor.user.id, + wanted, + ), + permissionStore.readUserGroupPerms(actor.user.id, wanted), + permissionStore.getFlatUserPerms(actor.user.id, wanted), + ]); + const held = + linked.length > 0 || + group.length > 0 || + flat.some((value) => value && !value.deleted); + return held ? {} : undefined; + }, + }); + // -- app-owns-appdata ----------------------------------------- // Mirror of the ACLService short-circuit at ACLService.check: // an app-under-user actor implicitly holds fs::* on any @@ -316,6 +379,8 @@ export class FSService extends PuterService { see: ['list', 'read', 'write'], list: ['read', 'write'], read: ['write'], + // Widens to nothing, but still emits the manage arm. + write: [], }; permissions.registerExploder({ id: 'fs-access-levels', @@ -628,6 +693,24 @@ export class FSService extends PuterService { ); } + // An overwrite reuses the entry's uuid as the object key, so the + // bytes must land in the bucket the entry already lives in — + // resolved the same way reads resolve it. Uploading to this + // server's bucket instead would repoint the row and strand the + // old object in the old bucket: a storage leak, and the replaced + // content survives there unreferenced. + if (existingEntry?.bucket) { + normalizedInput = { + ...normalizedInput, + bucket: this.stores.s3Object.resolveBucket( + existingEntry.bucket, + ), + bucketRegion: this.stores.s3Object.resolveRegion( + existingEntry.bucketRegion, + ), + }; + } + reservedPaths.add(normalizedInput.path); results.push({ index: input.index, @@ -698,6 +781,61 @@ export class FSService extends PuterService { return Math.max(allowanceMax, storageAllowanceMaxOverride); } + /** + * The allowance override to apply when charging `owner` for a write the + * acting user performs. The controller reads the override off the ACTING + * user's row, so it may only widen that same user's cap — a write into a + * tree someone else owns is judged against the owner's own allowance, or a + * recipient on a large plan could fill an owner's account past its limit. + * The unlimited sentinel passes through: it is server-authored (never + * derived from a user row) and means "don't meter this write". + */ + #allowanceOverrideFor( + owner: number, + actingUserId: number, + override?: number, + ): number | undefined { + if (override === UNLIMITED_STORAGE_ALLOWANCE) return override; + return owner === actingUserId ? override : undefined; + } + + /** + * Whose allowance a write to `path` draws on — the owner of the tree it + * lands in, which is not the writer when the folder was shared with them. + */ + async #storageOwnerOf(path: string, actingUserId: number): Promise { + const owners = await this.#storageOwnersOf([path], actingUserId); + return owners.get(path) ?? actingUserId; + } + + /** + * As above, for a batch. Every item under one home resolves to the same + * owner, so the distinct homes are read in a single query rather than one + * lookup per item. + */ + async #storageOwnersOf( + paths: string[], + actingUserId: number, + ): Promise> { + const homeOf = new Map(); + for (const path of paths) { + homeOf.set( + path, + `/${this.#normalizePath(path).split('/')[1] ?? ''}`, + ); + } + const homes = [...new Set(homeOf.values())].filter( + (home) => home !== '/', + ); + const entries = await this.stores.fsEntry.getEntriesByPaths(homes); + return new Map( + paths.map((path) => [ + path, + entries.get(homeOf.get(path) as string)?.userId ?? actingUserId, + ]), + ); + } + async #assertStorageAllowance( userId: number, incomingSize: number, @@ -1364,23 +1502,42 @@ export class FSService extends PuterService { } } - const sizeChanges = preparedBatch.items.map((item) => { + // One batch can straddle two trees, and each owner pays for its own. + const owners = await this.#storageOwnersOf( + preparedBatch.items.map((item) => item.normalizedInput.path), + preparedBatch.userId, + ); + const sizeChangesByOwner = new Map< + number, + Array<{ incomingSize: number; existingSize: number }> + >(); + for (const item of preparedBatch.items) { const uploadedItem = uploadedItemMap.get(item.index); - return { + const owner = + owners.get(item.normalizedInput.path) ?? preparedBatch.userId; + const sizeChanges = sizeChangesByOwner.get(owner) ?? []; + sizeChanges.push({ incomingSize: uploadedItem ? uploadedItem.uploadedSize : item.normalizedInput.size, existingSize: item.existingEntry?.size ?? 0, - }; - }); + }); + sizeChangesByOwner.set(owner, sizeChanges); + } const storageAllowanceMax = storageAllowanceMaxOverride ?? preparedBatch.storageAllowanceMax; - await this.#assertStorageAllowanceForBatch( - preparedBatch.userId, - sizeChanges, - storageAllowanceMax, - ); + for (const [owner, sizeChanges] of sizeChangesByOwner) { + await this.#assertStorageAllowanceForBatch( + owner, + sizeChanges, + this.#allowanceOverrideFor( + owner, + preparedBatch.userId, + storageAllowanceMax, + ), + ); + } } async uploadPreparedBatchItem( @@ -1577,11 +1734,18 @@ export class FSService extends PuterService { const parentPath = pathPosix.dirname(normalizedInput.path); const [, { parentEntries, createdDirectoryEntries }] = await Promise.all([ - this.#assertStorageAllowance( - userId, - normalizedInput.size, - existingSize, - storageAllowanceMax, + this.#storageOwnerOf(normalizedInput.path, userId).then( + (owner) => + this.#assertStorageAllowance( + owner, + normalizedInput.size, + existingSize, + this.#allowanceOverrideFor( + owner, + userId, + storageAllowanceMax, + ), + ), ), this.stores.fsEntry.resolveParentDirectoriesBatchWithCreated( userId, @@ -1801,15 +1965,22 @@ export class FSService extends PuterService { }; }); - const allowanceChecks: Array<{ - incomingSize: number; - existingSize: number; - }> = []; + const owners = await this.#storageOwnersOf( + resolvedFileItems.map((item) => item.normalizedInput.path), + userId, + ); + const allowanceChecksByOwner = new Map< + number, + Array<{ incomingSize: number; existingSize: number }> + >(); for (const item of resolvedFileItems) { - allowanceChecks.push({ + const owner = owners.get(item.normalizedInput.path) ?? userId; + const checks = allowanceChecksByOwner.get(owner) ?? []; + checks.push({ incomingSize: item.normalizedInput.size, existingSize: item.existingEntry?.size ?? 0, }); + allowanceChecksByOwner.set(owner, checks); } const [ , @@ -1818,10 +1989,18 @@ export class FSService extends PuterService { createdDirectoryEntries: createdParentDirectoryEntries, }, ] = await Promise.all([ - this.#assertStorageAllowanceForBatch( - userId, - allowanceChecks, - storageAllowanceMax, + Promise.all( + [...allowanceChecksByOwner].map(([owner, checks]) => + this.#assertStorageAllowanceForBatch( + owner, + checks, + this.#allowanceOverrideFor( + owner, + userId, + storageAllowanceMax, + ), + ), + ), ), this.stores.fsEntry.resolveParentDirectoriesBatchWithCreated( userId, @@ -2645,11 +2824,20 @@ export class FSService extends PuterService { normalizedInput.thumbnail = null; const existingSize = existingEntry?.size ?? 0; - await this.#assertStorageAllowance( + const storageOwner = await this.#storageOwnerOf( + normalizedInput.path, userId, + ); + const ownerAllowanceMax = this.#allowanceOverrideFor( + storageOwner, + userId, + storageAllowanceMax, + ); + await this.#assertStorageAllowance( + storageOwner, normalizedInput.size, existingSize, - storageAllowanceMax, + ownerAllowanceMax, ); const uploadBody = await this.#toUploadBody( @@ -2683,10 +2871,10 @@ export class FSService extends PuterService { } if (uploadedSize > normalizedInput.size) { await this.#assertStorageAllowance( - userId, + storageOwner, uploadedSize, existingSize, - storageAllowanceMax, + ownerAllowanceMax, ); } normalizedInput.size = uploadedSize; @@ -3118,7 +3306,6 @@ export class FSService extends PuterService { let created: FSEntry; try { created = await this.stores.fsEntry.createNonFileEntry({ - userId, parent, name, kind: 'directory', @@ -3190,7 +3377,6 @@ export class FSService extends PuterService { }); } const created = await this.stores.fsEntry.createNonFileEntry({ - userId, parent, name, kind: 'empty-file', @@ -3203,7 +3389,12 @@ export class FSService extends PuterService { * Rename an entry in place. The name changes and path rewrites; if the * entry is a directory, descendant paths are rewritten too. */ - async rename(entry: FSEntry, newName: string): Promise { + async rename( + userId: number, + entry: FSEntry, + newName: string, + ): Promise { + await this.#assertCanRename(entry, userId); if (newName.includes('/')) throw new HttpError(400, 'Name cannot contain a slash', { legacyCode: 'bad_request', @@ -3279,7 +3470,6 @@ export class FSService extends PuterService { } } const created = await this.stores.fsEntry.createNonFileEntry({ - userId, parent: input.parent, name, kind: 'shortcut', @@ -3291,13 +3481,6 @@ export class FSService extends PuterService { // -- Mutation: remove / move / copy --------------------------------- - /** - * Remove an entry. For directories, descendants are walked and removed - * (both DB rows and S3 objects). Emits `fs.remove.node` per file so the - * thumbnail extension (and any other listener) can clean up side state. - * - * Does NOT enforce ACL — caller (controller) performs the `write` check. - */ /** * Delete, move, and rename all ask ACL for `fs:write`, which cannot tell * them apart from an ordinary write — so the delete class is enforced here @@ -3331,6 +3514,70 @@ export class FSService extends PuterService { } } + /** + * A FILE shared directly with you is renameable with `write` on it — the + * name is the file's own, and rename stays in place. A folder's name is + * structure the owner's whole subtree hangs off, so folders (and anything + * reached inside a shared folder) go by the parent-write restructure rule + * instead. + */ + async #assertCanRename(entry: FSEntry, userId: number): Promise { + if (entry.userId !== userId && !entry.isDir) { + const actor = Context.get('actor') as Actor | undefined; + if (actor) { + const allowed = await this.services.acl.check( + actor, + { + path: entry.path, + resolveAncestors: () => + this.getAncestorChain(entry.path), + }, + 'write', + ); + if (allowed) return; + } + } + + await this.#assertCanRestructure(entry, userId); + } + + /** + * Move and delete are authorized by `write` on the parent, not on the entry + * — which is what lets a share recipient reorganize inside a shared folder + * without reaching the shared folder itself. + */ + async #assertCanRestructure(entry: FSEntry, userId: number): Promise { + if (entry.userId === userId) return; + + const actor = Context.get('actor') as Actor | undefined; + const parentPath = pathPosix.dirname(entry.path); + if (actor && parentPath !== '/') { + const allowed = await this.services.acl.check( + actor, + { + path: parentPath, + resolveAncestors: () => this.getAncestorChain(parentPath), + }, + 'write', + ); + if (allowed) return; + } + + throw new HttpError( + 403, + 'Cannot restructure an entry owned by another user', + { legacyCode: 'forbidden' }, + ); + } + + /** + * Remove an entry. For directories, descendants are walked and removed + * (both DB rows and S3 objects). Emits `fs.remove.node` per file so the + * thumbnail extension (and any other listener) can clean up side state. + * + * The caller checks `write` on the entry; the parent check that governs + * restructuring is enforced here. + */ async remove( userId: number, input: { @@ -3349,20 +3596,11 @@ export class FSService extends PuterService { if (!input.systemInitiated) { await this.#assertCrossAppDeleteAllowed(entry.path); } - if (entry.userId !== userId) { - // Defensive — only the owner should be hitting this path; higher - // layers grant access via ACL, not raw ownership, but we still - // want to avoid a misrouted call taking out someone else's tree. - throw new HttpError( - 403, - 'Cannot remove an entry owned by another user', - { legacyCode: 'forbidden' }, - ); - } + await this.#assertCanRestructure(entry, userId); if (entry.isDir) { const descendants = await this.stores.fsEntry.listDescendantsByPath( - userId, + entry.userId, entry.path, ); if (descendants.length > 0 && !input.recursive) { @@ -3584,10 +3822,15 @@ export class FSService extends PuterService { // The source only: moving *into* another app's AppData is a write, and // ACL plus the fs:write class already cover that. await this.#assertCrossAppDeleteAllowed(source.path); - if (source.userId !== userId) { + await this.#assertCanRestructure(source, userId); + // Write inside a shared folder reorganizes it, it does not empty it. + if ( + source.userId !== userId && + source.userId !== destinationParent.userId + ) { throw new HttpError( 403, - 'Cannot move an entry owned by another user', + "Cannot move an entry out of its owner's tree", { legacyCode: 'forbidden' }, ); } @@ -3647,9 +3890,20 @@ export class FSService extends PuterService { this.#stripReservedMetadataKeys(input.newMetadata), ); + // Moving your entry into another tree hands it over, bytes included, + // so they have to fit the new owner's allowance. + const newOwnerId = destinationParent.userId; + if (newOwnerId !== source.userId) { + await this.#assertStorageAllowance( + newOwnerId, + await this.#entryStorageSize(source), + ); + } + const updated = await this.stores.fsEntry.updateEntry(source.uuid, { name, path: finalPath, + userId: newOwnerId, parentId: destinationParent.id, parentUid: destinationParent.uuid, ...(metadataPatch !== undefined ? { metadata: metadataPatch } : {}), @@ -3657,9 +3911,10 @@ export class FSService extends PuterService { if (source.isDir && source.path !== finalPath) { await this.stores.fsEntry.updatePathPrefixForUser( - userId, + source.userId, source.path, finalPath, + newOwnerId, ); } @@ -3727,12 +3982,16 @@ export class FSService extends PuterService { // the allowance as writing them. Check before the overwrite below // removes anything, and credit what that removal frees. await this.#assertStorageAllowance( - userId, + destinationParent.userId, await this.#entryStorageSize(source), collision && input.overwrite ? await this.#entryStorageSize(collision) : 0, - input.storageAllowanceMax, + this.#allowanceOverrideFor( + destinationParent.userId, + userId, + input.storageAllowanceMax, + ), ); if (collision) { @@ -3776,7 +4035,6 @@ export class FSService extends PuterService { // 2) Walk descendants; for each, compute new path by swapping prefix // 3) Create a new row (files copy S3 object; dirs just insert) const newRoot = await this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name, kind: 'directory', @@ -3807,7 +4065,6 @@ export class FSService extends PuterService { } const copied = descendant.isDir ? await this.stores.fsEntry.createNonFileEntry({ - userId, parent: newParent, name: descendant.name, kind: 'directory', @@ -3843,7 +4100,6 @@ export class FSService extends PuterService { ): Promise { if (source.isSymlink) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'symlink', @@ -3854,7 +4110,6 @@ export class FSService extends PuterService { } if (source.isShortcut) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'shortcut', @@ -3870,7 +4125,6 @@ export class FSService extends PuterService { // the source as another empty-file entry instead of touching S3. if (hasNoBackingS3Object(source)) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'empty-file', diff --git a/src/backend/services/fs/resolveNode.test.ts b/src/backend/services/fs/resolveNode.test.ts index e07c036b5e..1828907244 100644 --- a/src/backend/services/fs/resolveNode.test.ts +++ b/src/backend/services/fs/resolveNode.test.ts @@ -24,6 +24,7 @@ import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js'; import { assertNormalized, expandTildePath, + isOwnersTrash, joinChildPath, normalizeAbsolutePath, resolveNode, @@ -347,3 +348,42 @@ describe('joinChildPath', () => { ); }); }); + +describe('isOwnersTrash', () => { + const trash = (over: Record = {}) => ({ + userId: 1, + isDir: true, + name: 'Trash', + path: '/alice/Trash', + ...over, + }); + + it('accepts the top-level Trash of the entry owner', () => { + expect(isOwnersTrash({ userId: 1 }, trash())).toBe(true); + }); + + it('rejects a Trash belonging to someone else', () => { + expect(isOwnersTrash({ userId: 2 }, trash())).toBe(false); + }); + + it('rejects a nested folder that happens to be named Trash', () => { + expect( + isOwnersTrash({ userId: 1 }, trash({ path: '/alice/Documents/Trash' })), + ).toBe(false); + }); + + it('rejects a folder inside Trash', () => { + expect( + isOwnersTrash( + { userId: 1 }, + trash({ name: 'old', path: '/alice/Trash/old' }), + ), + ).toBe(false); + }); + + it('rejects a file named Trash', () => { + expect(isOwnersTrash({ userId: 1 }, trash({ isDir: false }))).toBe( + false, + ); + }); +}); diff --git a/src/backend/services/fs/resolveNode.ts b/src/backend/services/fs/resolveNode.ts index 978cdceb78..be16fa6ec6 100644 --- a/src/backend/services/fs/resolveNode.ts +++ b/src/backend/services/fs/resolveNode.ts @@ -196,3 +196,20 @@ export function joinChildPath(parentPath: string, name: string): string { const parent = normalizeAbsolutePath(parentPath); return parent === '/' ? `/${name}` : `${parent}/${name}`; } + +/** + * The top-level Trash of `entry`'s own owner. Moving there is how an entry gets + * deleted, so it goes by rights over the entry — a share recipient deleting + * inside a shared folder has no access to the owner's Trash. + */ +export function isOwnersTrash( + entry: { userId: number }, + destination: { userId: number; isDir: boolean; name: string; path: string }, +): boolean { + return ( + destination.userId === entry.userId && + destination.isDir && + destination.name === 'Trash' && + pathPosix.dirname(pathPosix.dirname(destination.path)) === '/' + ); +} diff --git a/src/backend/services/fs/rootListing.test.ts b/src/backend/services/fs/rootListing.test.ts index 59614f1289..7d866d0c93 100644 --- a/src/backend/services/fs/rootListing.test.ts +++ b/src/backend/services/fs/rootListing.test.ts @@ -66,8 +66,7 @@ const makeUser = async () => { return { userId: created.id, username, actor }; }; -const listFor = (actor: Actor) => - listRootEntries(actor, fsEntryStore, permissionService); +const listFor = (actor: Actor) => listRootEntries(actor, fsEntryStore); describe('listRootEntries', () => { it('shows the actor their own home directory, exactly once', async () => { @@ -91,7 +90,7 @@ describe('listRootEntries', () => { ); }); - it('adds the home of every user who has granted the actor a permission', async () => { + it('leaves an issuer’s home out of root when only a file was shared', async () => { const holder = await makeUser(); const issuer = await makeUser(); const shared = (await fsEntryStore.getEntryByPath( @@ -105,9 +104,11 @@ describe('listRootEntries', () => { const entries = await listFor(holder.actor); - expect(entries.map((entry) => entry.path).sort()).toEqual( - [`/${holder.username}`, `/${issuer.username}`].sort(), - ); + // Listing it here would advertise a folder readdir then refuses to + // open: the grant is on Documents, which says nothing about its parent. + expect(entries.map((entry) => entry.path)).toEqual([ + `/${holder.username}`, + ]); }); it('heals a home row whose path drifted from the username', async () => { diff --git a/src/backend/services/fs/rootListing.ts b/src/backend/services/fs/rootListing.ts index 43caf0cb88..47cb4d1a99 100644 --- a/src/backend/services/fs/rootListing.ts +++ b/src/backend/services/fs/rootListing.ts @@ -20,19 +20,18 @@ import type { Actor } from '../../core/actor.js'; import type { FSEntry } from '../../stores/fs/FSEntry.js'; import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js'; -import type { PermissionService } from '../permission/PermissionService.js'; /** * Synthesize the listing for the virtual root `/`. There is no fsentry row at - * `/` — instead root is a virtual aggregate of user-directory entries the actor - * can see: the actor's own home plus any other users' homes granted via - * permission issuers (i.e. users that have shared something with this actor). - * Mirrors v1's `LLListUsers`. + * `/` — root stands in for the actor's own home. + * + * Issuer homes are deliberately absent: a grant on a file says nothing about + * its ancestors, so listing them advertised folders `readdir` then refused to + * open. Shares are reached through the sharing API instead. */ export async function listRootEntries( actor: Actor, fsEntryStore: FSEntryStore, - permissionService: PermissionService, ): Promise { const entries: FSEntry[] = []; const seenPaths = new Set(); @@ -69,15 +68,5 @@ export async function listRootEntries( await pushByUsername(actor.user.username); - if (typeof userId === 'number') { - const issuers = await permissionService.listUserPermissionIssuers({ - id: userId, - }); - for (const issuer of issuers) { - if (!issuer) continue; - await pushByUsername(issuer.username); - } - } - return entries; } diff --git a/src/backend/services/fs/sharePathMask.test.ts b/src/backend/services/fs/sharePathMask.test.ts new file mode 100644 index 0000000000..55e22f9b8d --- /dev/null +++ b/src/backend/services/fs/sharePathMask.test.ts @@ -0,0 +1,170 @@ +/* + * 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 . + */ + +import { describe, expect, it } from 'vitest'; +import { + SharePathMasker, + parseMaskedSharePath, + resolveSharePath, +} from './sharePathMask'; +import type { FSEntryStore } from '../../stores/fs/FSEntryStore'; +import type { Actor } from '../../core/actor'; + +const UID = '11111111-2222-3333-4444-555555555555'; + +const actorFor = (username: string, id: number) => + ({ user: { id, username } }) as Actor; + +const storeWith = (entries: Record) => + ({ + getEntryByUuid: async (uuid: string) => entries[uuid] ?? null, + }) as unknown as FSEntryStore; + +describe('parseMaskedSharePath', () => { + it('reads owner, root and tail', () => { + expect(parseMaskedSharePath(`/alice/${UID}/Report.pdf`)).toEqual({ + ownerUsername: 'alice', + rootUuid: UID, + tail: 'Report.pdf', + }); + }); + + it('rejects anything whose second segment is not a uuid', () => { + expect(parseMaskedSharePath('/alice/Documents/x.txt')).toBeNull(); + expect(parseMaskedSharePath('/alice')).toBeNull(); + expect(parseMaskedSharePath('relative/path')).toBeNull(); + }); +}); + +describe('resolveSharePath', () => { + const store = storeWith({ + [UID]: { uuid: UID, name: 'Work', path: '/alice/Documents/Work' }, + }); + const bob = actorFor('bob', 2); + + it('rewrites a masked path to the owner’s real one', async () => { + expect(await resolveSharePath(store, bob, `/alice/${UID}/Work`)).toBe( + '/alice/Documents/Work', + ); + expect( + await resolveSharePath(store, bob, `/alice/${UID}/Work/sub/f.txt`), + ).toBe('/alice/Documents/Work/sub/f.txt'); + }); + + it('leaves your own paths alone without a lookup', async () => { + const alice = actorFor('alice', 1); + expect(await resolveSharePath(store, alice, `/alice/${UID}/Work`)).toBe( + `/alice/${UID}/Work`, + ); + }); + + it('refuses to address a sibling the mask does not name', async () => { + // The segment after the uuid has to be the shared entry's own name, + // or the mask becomes a way to walk the owner's folder by guessing. + expect( + await resolveSharePath(store, bob, `/alice/${UID}/Secrets/x.txt`), + ).toBe(`/alice/${UID}/Secrets/x.txt`); + }); + + it('will not resolve the masked root on its own', async () => { + // It stands in for the owner's parent directory, which is not the + // recipient's to reach. + expect(await resolveSharePath(store, bob, `/alice/${UID}`)).toBe( + `/alice/${UID}`, + ); + }); + + it('refuses a mask naming someone other than the entry’s owner', async () => { + // A folder of alice's literally named with the uuid of carol's file + // must not redirect a caller onto carol's file — or onto anything the + // uuid resolves to that alice does not own. + const carolStore = storeWith({ + [UID]: { uuid: UID, name: 'notes.txt', path: '/carol/notes.txt' }, + }); + expect( + await resolveSharePath(carolStore, bob, `/alice/${UID}/notes.txt`), + ).toBe(`/alice/${UID}/notes.txt`); + }); + + it('refuses dot segments below the root', async () => { + expect( + await resolveSharePath(store, bob, `/alice/${UID}/Work/../../x`), + ).toBe(`/alice/${UID}/Work/../../x`); + }); + + it('passes an unknown uuid through as a literal path', async () => { + const other = '99999999-2222-3333-4444-555555555555'; + expect(await resolveSharePath(store, bob, `/alice/${other}/x`)).toBe( + `/alice/${other}/x`, + ); + }); +}); + +describe('SharePathMasker', () => { + it('leaves entries you own untouched', () => { + const masker = new SharePathMasker(1); + expect( + masker.mask({ + path: '/alice/Documents/f.txt', + uuid: UID, + name: 'f.txt', + userId: 1, + }), + ).toBe('/alice/Documents/f.txt'); + }); + + it('masks a foreign entry against itself when no root is known', () => { + const masker = new SharePathMasker(2); + expect( + masker.mask({ + path: '/alice/Documents/Work/f.txt', + uuid: UID, + name: 'f.txt', + userId: 1, + }), + ).toBe(`/alice/${UID}/f.txt`); + }); + + it('keeps a learned root, so a shared tree stays navigable', () => { + const masker = new SharePathMasker(2); + masker.learn('/alice/Documents/Work', `/alice/${UID}/Work`); + expect( + masker.mask({ + path: '/alice/Documents/Work/sub/f.txt', + uuid: 'child-uuid', + name: 'f.txt', + userId: 1, + }), + ).toBe(`/alice/${UID}/Work/sub/f.txt`); + }); + + it('prefers the deepest learned root', () => { + const masker = new SharePathMasker(2); + masker.learn('/alice/Documents', `/alice/${UID}/Documents`); + masker.learn('/alice/Documents/Work', '/alice/deeper/Work'); + expect( + masker.mask({ + path: '/alice/Documents/Work/f.txt', + uuid: 'child-uuid', + name: 'f.txt', + userId: 1, + }), + ).toBe('/alice/deeper/Work/f.txt'); + }); +}); diff --git a/src/backend/services/fs/sharePathMask.ts b/src/backend/services/fs/sharePathMask.ts new file mode 100644 index 0000000000..6078ce8034 --- /dev/null +++ b/src/backend/services/fs/sharePathMask.ts @@ -0,0 +1,203 @@ +/* + * 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 . + */ + +import { posix as pathPosix } from 'node:path'; +import { Context } from '../../core/context.js'; +import type { Actor } from '../../core/actor.js'; +import type { FSEntry } from '../../stores/fs/FSEntry.js'; +import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js'; + +/** + * Addressing for entries someone else owns. + * + * A recipient is given `///[/…]`, where `` stands in + * for everything above the shared item. It says where the item is without + * saying where its owner keeps it — the folder it sits in, and what sits beside + * it, are the owner's business. The form is self-describing, so the backend + * resolves it back to the real path with one lookup and no server-side + * session. + * + * Only foreign entries are masked. Your own paths are untouched. + */ + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +/** Context key for the per-request masker. */ +const MASKER_KEY = 'fs.sharePathMasker'; + +export interface MaskedSharePath { + ownerUsername: string; + rootUuid: string; + /** Path below the masked root, `''` when the path is the root itself. */ + tail: string; +} + +/** Read `//[/tail]` without touching the database. */ +export function parseMaskedSharePath(path: string): MaskedSharePath | null { + if (typeof path !== 'string' || !path.startsWith('/')) return null; + const [ownerUsername, rootUuid, ...rest] = path.slice(1).split('/'); + if (!ownerUsername || !rootUuid || !UUID_PATTERN.test(rootUuid)) { + return null; + } + return { ownerUsername, rootUuid, tail: rest.join('/') }; +} + +/** + * Turn a masked path back into the real one, or return it unchanged when it + * isn't masked. + * + * A path under your own username is never masked, so it is passed through + * without a lookup. Anything else is only rewritten when the uuid resolves and + * the segment after it is that entry's own name — otherwise the caller is + * naming a real path that happens to look like a mask, and gets it verbatim. + */ +export async function resolveSharePath( + fsEntryStore: FSEntryStore, + actor: Actor | undefined, + path: string, +): Promise { + const parsed = parseMaskedSharePath(path); + if (!parsed) return path; + if (parsed.ownerUsername === actor?.user?.username) return path; + + const root = await fsEntryStore.getEntryByUuid(parsed.rootUuid); + if (!root) return path; + // The mask names the owner, so the uuid must be theirs. Without this, a + // folder deliberately named after some other entry's uuid would redirect + // a caller who thinks they are inside it onto that entry instead. + if (root.path.split('/')[1] !== parsed.ownerUsername) return path; + + const [head, ...rest] = parsed.tail.split('/').filter(Boolean); + // The masked root stands in for the owner's parent directory, which the + // recipient has no business addressing. + if (head === undefined || head !== root.name) return path; + + const real = [root.path, ...rest].join('/'); + // `rest` is caller-authored: a `.`/`..` segment would walk out of the + // shared subtree the uuid vouched for. + if (pathPosix.normalize(real) !== real) return path; + maskerFor(actor)?.learn( + root.path, + `/${parsed.ownerUsername}/${root.uuid}/${root.name}`, + ); + return real; +} + +/** + * Rewrites outbound entry paths for one request. + * + * `learn` records the mapping an inbound masked path already proved, so every + * entry reached through it keeps the same root and stays navigable. Anything + * else falls back to masking the entry against itself, which always resolves + * but flattens the tree — correct, just less useful for browsing. + */ +export class SharePathMasker { + readonly #actorUserId: number | undefined; + readonly #roots = new Map(); + + constructor(actorUserId: number | undefined) { + this.#actorUserId = actorUserId; + } + + learn(realPrefix: string, maskedPrefix: string): void { + this.#roots.set(realPrefix, maskedPrefix); + } + + /** The path to publish for `entry`. */ + mask(entry: { + path: string; + uuid: string; + name?: string; + userId?: number; + }): string { + if ( + this.#actorUserId === undefined || + entry.userId === undefined || + entry.userId === this.#actorUserId + ) { + return entry.path; + } + + let bestReal: string | null = null; + for (const real of this.#roots.keys()) { + if (entry.path !== real && !entry.path.startsWith(`${real}/`)) { + continue; + } + if (bestReal === null || real.length > bestReal.length) { + bestReal = real; + } + } + if (bestReal !== null) { + return ( + (this.#roots.get(bestReal) as string) + + entry.path.slice(bestReal.length) + ); + } + + const owner = entry.path.split('/')[1]; + const name = entry.name ?? pathPosix.basename(entry.path); + if (!owner || !name) return entry.path; + return `/${owner}/${entry.uuid}/${name}`; + } +} + +/** + * The masker for the current request, created on first use. + * + * Request-scoped rather than an argument: it would otherwise thread through + * every FS response shaper and each of their ~30 call sites, and the only thing + * it depends on is the actor, which already lives here. + */ +export function maskerFor(actor: Actor | undefined): SharePathMasker | null { + if (!Context.current()) return null; + const existing = Context.get(MASKER_KEY); + if (existing instanceof SharePathMasker) return existing; + const masker = new SharePathMasker(actor?.user?.id); + Context.set(MASKER_KEY, masker); + return masker; +} + +/** Mask `entry`'s path for the current request. */ +export function maskEntryPath(entry: { + path: string; + uuid: string; + name?: string; + userId?: number; +}): string { + const masker = maskerFor(Context.get('actor')); + return masker ? masker.mask(entry) : entry.path; +} + +/** + * Teach the masker the roots `entries` were shared at, so a listing that never + * went through a masked path still shows them as one navigable tree. + */ +export async function learnShareRoots( + roots: Array>, + actor: Actor | undefined, +): Promise { + const masker = maskerFor(actor); + if (!masker) return; + for (const root of roots) { + const owner = root.path.split('/')[1]; + if (!owner) continue; + masker.learn(root.path, `/${owner}/${root.uuid}/${root.name}`); + } +} diff --git a/src/backend/services/homepage/PuterHomepageService.ts b/src/backend/services/homepage/PuterHomepageService.ts index 2a38744b79..9be7c0fee0 100644 --- a/src/backend/services/homepage/PuterHomepageService.ts +++ b/src/backend/services/homepage/PuterHomepageService.ts @@ -185,6 +185,10 @@ export class PuterHomepageService extends PuterService { app_origin: this.#originFromRequest(req), gui_origin: this.#originFromRequest(req), hosting_domain: this.config.static_hosting_domain, + // The GUI loads the SDK itself in bundled mode, so it needs this + // to serve its own build rather than the public CDN. + puterjs_bundle: + this.config.gui_puterjs_bundle ?? 'https://js.puter.com/v2/', asset_dir: assetDir, captchaRequired, ...meta, diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts index d6e033163e..1089d35c0d 100644 --- a/src/backend/services/index.ts +++ b/src/backend/services/index.ts @@ -27,6 +27,7 @@ import { AuthService } from './auth/AuthService'; import { OIDCService } from './auth/OIDCService'; import { TokenService } from './auth/TokenService'; import { BroadcastService } from './broadcast/BroadcastService'; +import { CacheReplicationService } from './cache/CacheReplicationService'; import { AppFeedbackService } from './feedback/AppFeedbackService'; import { FSService } from './fs/FSService'; import { ServerHealthService } from './health/ServerHealthService'; @@ -36,6 +37,7 @@ import { MeteringService } from './metering/MeteringService'; import { NotificationService } from './notification/NotificationService'; import { PermissionService } from './permission/PermissionService'; import { DefaultUserService } from './selfhosted/DefaultUserService'; +import { ShareService } from './share/ShareService'; import { SocketService } from './socket/SocketService'; import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService'; import type { IPuterServiceRegistry } from './types'; @@ -54,6 +56,7 @@ declare module './types' { appOriginBlocklist: AppOriginBlocklistService; permission: PermissionService; acl: ACLService; + share: ShareService; token: TokenService; auth: AuthService; fs: FSService; @@ -65,6 +68,7 @@ declare module './types' { notification: NotificationService; appFeedback: AppFeedbackService; broadcast: BroadcastService; + cacheReplication: CacheReplicationService; oidc: OIDCService; appIcon: AppIconService; defaultUser: DefaultUserService; @@ -93,6 +97,9 @@ export const puterServices = { token: TokenService, auth: AuthService, fs: FSService, + // Needs acl (setUserUser), permission (canManagePermission) and fs + // (ancestor chains), so it follows all three. + share: ShareService, // Declared after `fs` — account teardown tears the user's filesystem down // first. userAccount: UserAccountService, @@ -110,6 +117,8 @@ export const puterServices = { // AuthService.appUidFromOrigin). appFeedback: AppFeedbackService, broadcast: BroadcastService, + // Independent — only needs the event client and redis. + cacheReplication: CacheReplicationService, oidc: OIDCService, appIcon: AppIconService, defaultUser: DefaultUserService, diff --git a/src/backend/services/permission/PermissionService.test.ts b/src/backend/services/permission/PermissionService.test.ts index 762f717362..f63ef448f3 100644 --- a/src/backend/services/permission/PermissionService.test.ts +++ b/src/backend/services/permission/PermissionService.test.ts @@ -321,6 +321,44 @@ describe('PermissionService (integration)', () => { ).rejects.toMatchObject({ statusCode: 404 }); }); + it('lets a holder give up a permission it cannot manage', async () => { + const { user: issuer, actor: issuerActor } = await makeUserActor(); + const { user: target, actor: targetActor } = await makeUserActor(); + const permission = `zztest:self-revoke-${uuidv4()}:ii:read`; + await server.stores.permission.setFlatUserPerm( + issuer.id, + `manage:${permission}`, + { + permission: `manage:${permission}`, + deleted: false, + issuer_user_id: issuer.id, + } as never, + ); + await runWithContext({ actor: issuerActor }, () => + permService.grantUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + expect(await permService.check(targetActor, permission)).toBe(true); + + // The holder has no manage authority here — renouncing access is + // allowed anyway, since it can only narrow their own reach. + await runWithContext({ actor: targetActor }, () => + permService.revokeUserUserPermission( + targetActor, + target.username, + permission, + {}, + { issuerUserId: issuer.id }, + ), + ); + expect( + await permService.check(targetActor, permission), + ).toBeFalsy(); + }); + it('revokeUserUserPermission throws 403 when the issuer lacks manage', async () => { const { actor: issuer } = await makeUserActor(); const { user: target } = await makeUserActor(); @@ -583,37 +621,8 @@ describe('PermissionService (integration)', () => { }); }); - describe('listUserPermissionIssuers / queryIssuerHolderPermissionsByPrefix', () => { - it('listUserPermissionIssuers returns the issuer who granted the target a perm', async () => { - const { user: issuer, actor: issuerActor } = await makeUserActor(); - const { user: target } = await makeUserActor(); - const permission = `zztest:lst-${uuidv4()}:ii:read`; - await server.stores.permission.setFlatUserPerm( - issuer.id, - `manage:${permission}`, - { - permission: `manage:${permission}`, - deleted: false, - issuer_user_id: issuer.id, - } as never, - ); - await runWithContext({ actor: issuerActor }, () => - permService.grantUserUserPermission( - issuerActor, - target.username, - permission, - ), - ); - // listUserPermissionIssuers is best-effort; just verify it runs - // and either includes the issuer or returns an empty array (the - // linked store may not be populated immediately). - const issuers = await permService.listUserPermissionIssuers({ - id: target.id, - }); - expect(Array.isArray(issuers)).toBe(true); - }); - - it('queryIssuerHolderPermissionsByPrefix returns [] for actors without user.id', async () => { + describe('queryIssuerHolderPermissionsByPrefix', () => { + it('returns [] for actors without user.id', async () => { const out = await permService.queryIssuerHolderPermissionsByPrefix( { user: undefined } as unknown as Actor, { user: undefined } as unknown as Actor, @@ -943,6 +952,206 @@ describe('PermissionService (integration)', () => { ); }); + it('drops the flat entry even when a lagging replica still shows the deleted row', async () => { + const { user: issuer, actor: issuerActor } = await makeUserActor(); + const { user: target } = await makeUserActor(); + const permission = `zztest:rvk-lag-${uuidv4()}:ii:read`; + await grantManage(issuer, permission); + await runWithContext({ actor: issuerActor }, () => + permService.grantUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + + // Simulate replica lag at the store boundary (sqlite has no + // replica to lag): the plain read path keeps returning the row + // the revoke just deleted from the primary. The remaining-check + // must go through the primary-read variant instead — trusting + // the replica view (or re-warming the row cache from it) skips + // the flat delete and leaves a no-TTL flat grant standing with + // no SQL rows behind it. + const staleRow = { + holder_user_id: target.id, + issuer_user_id: issuer.id, + permission, + extra: {}, + }; + const spy = vi + .spyOn(server.stores.permission, 'readLinkedUserUserPerms') + .mockResolvedValue([staleRow as never]); + try { + await runWithContext({ actor: issuerActor }, () => + permService.revokeUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + } finally { + spy.mockRestore(); + } + + const flat = await server.stores.permission.getFlatUserPerms( + target.id, + [permission], + ); + expect(flat.filter((v) => !v.deleted)).toHaveLength(0); + }); + + it('grantUserUserPermission persists the linked SQL row before resolving', async () => { + const { user: issuer, actor: issuerActor } = await makeUserActor(); + const { user: target } = await makeUserActor(); + const permission = `zztest:grant-sync-${uuidv4()}:ii:read`; + await grantManage(issuer, permission); + + await runWithContext({ actor: issuerActor }, () => + permService.grantUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + + // A fire-and-forget upsert leaves the flat view claiming a grant + // that nothing durable backs — losing it if the entry is ever + // dropped, with no SQL row to re-derive from. + const rows = await server.stores.permission.readLinkedUserUserPerms( + target.id, + [permission], + ); + expect(rows).toHaveLength(1); + expect(rows[0].issuer_user_id).toBe(issuer.id); + }); + + it('grantUserUserPermission surfaces a failed SQL upsert instead of swallowing it', async () => { + const { user: issuer, actor: issuerActor } = await makeUserActor(); + const { user: target, actor: targetActor } = await makeUserActor(); + const permission = `zztest:grant-fail-${uuidv4()}:ii:read`; + await grantManage(issuer, permission); + + const spy = vi + .spyOn(server.stores.permission, 'upsertUserUserPerm') + .mockRejectedValue(new Error('simulated db failure')); + try { + await expect( + runWithContext({ actor: issuerActor }, () => + permService.grantUserUserPermission( + issuerActor, + target.username, + permission, + ), + ), + ).rejects.toThrow('simulated db failure'); + } finally { + spy.mockRestore(); + } + + // Fails closed: the durable write went first, so a failure there + // leaves no flat entry granting access either. + expect(await permService.check(targetActor, permission)).toBeFalsy(); + }); + + it('keeps the flat entry while another issuer still grants the permission', async () => { + const { user: issuerA, actor: actorA } = await makeUserActor(); + const { user: issuerB, actor: actorB } = await makeUserActor(); + const { user: target, actor: targetActor } = await makeUserActor(); + const permission = `zztest:two-issuers-${uuidv4()}:ii:read`; + await grantManage(issuerA, permission); + await grantManage(issuerB, permission); + + for (const actor of [actorA, actorB]) { + await runWithContext({ actor }, () => + permService.grantUserUserPermission( + actor, + target.username, + permission, + ), + ); + } + + await runWithContext({ actor: actorA }, () => + permService.revokeUserUserPermission( + actorA, + target.username, + permission, + ), + ); + + // B's grant stands, so the shared flat key must survive with it — + // the key isn't issuer-scoped and B may not resolve via the chain. + expect(await permService.check(targetActor, permission)).toBe(true); + + await runWithContext({ actor: actorB }, () => + permService.revokeUserUserPermission( + actorB, + target.username, + permission, + ), + ); + expect( + await permService.check(targetActor, permission), + ).toBeFalsy(); + }); + + it('reports whether a grant was actually removed', async () => { + const { user: issuer, actor: issuerActor } = await makeUserActor(); + const { user: target } = await makeUserActor(); + const permission = `zztest:rvk-reports-${uuidv4()}:ii:read`; + await grantManage(issuer, permission); + await runWithContext({ actor: issuerActor }, () => + permService.grantUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + + const first = await runWithContext({ actor: issuerActor }, () => + permService.revokeUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + expect(first).toBe(true); + + // Nothing left to revoke — still not an error, but it must not + // claim to have removed something. + const second = await runWithContext({ actor: issuerActor }, () => + permService.revokeUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + expect(second).toBe(false); + }); + + it('writes no audit row for a revoke that matched nothing', async () => { + const { user: issuer, actor: issuerActor } = await makeUserActor(); + const { user: target } = await makeUserActor(); + const permission = `zztest:rvk-noaudit-${uuidv4()}:ii:read`; + await grantManage(issuer, permission); + + // Never granted, so there is no row to remove. + const revoked = await runWithContext({ actor: issuerActor }, () => + permService.revokeUserUserPermission( + issuerActor, + target.username, + permission, + ), + ); + expect(revoked).toBe(false); + + const rows = await server.clients.db.read( + 'SELECT `action` FROM `audit_user_to_user_permissions` WHERE `holder_user_id` = ? AND `permission` = ?', + [target.id, permission], + ); + expect(rows).toHaveLength(0); + }); + it('scan-path warms of the flat view carry an expiry (grants are permanent)', async () => { const { user: issuer, actor: issuerActor } = await makeUserActor(); const { user: target, actor: targetActor } = await makeUserActor(); diff --git a/src/backend/services/permission/PermissionService.ts b/src/backend/services/permission/PermissionService.ts index 1868b37a88..2e8999dc9a 100644 --- a/src/backend/services/permission/PermissionService.ts +++ b/src/backend/services/permission/PermissionService.ts @@ -845,7 +845,15 @@ export class PermissionService extends PuterService { }); const issuerId = actor.user.id; - // Flat upsert (awaited so callers see immediate effect) + // Durable row before the flat view, both awaited. A `manage:`-only + // delegate's grant resolves via flat and not via the linked chain, so + // writing SQL first makes a partial failure fail closed. + await this.stores.permission.upsertUserUserPerm( + user.id, + issuerId, + permission, + extra, + ); await this.stores.permission.setFlatUserPerm(user.id, permission, { ...extra, issuer_user_id: issuerId, @@ -853,10 +861,7 @@ export class PermissionService extends PuterService { deleted: false, }); - // Linked upsert + audit fire-and-forget. - this.stores.permission - .upsertUserUserPerm(user.id, issuerId, permission, extra) - .catch(() => {}); + // Off the critical path, but a silent drop makes the log untrustworthy. this.stores.permission .auditUserUserPerm({ holder_user_id: user.id, @@ -865,18 +870,33 @@ export class PermissionService extends PuterService { action: 'grant', reason: meta.reason ?? 'granted via PermissionService', }) - .catch(() => {}); + .catch((err) => { + console.warn( + '[PermissionService] failed to audit user-user grant:', + err, + ); + }); // Bust any cached "denied" reading so the grant is live immediately. if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid); } + /** + * Remove the grant `actor` issued, or the one named by `opts.issuerUserId` + * when the caller has established authority over another issuer's grant (a + * resource owner clearing a delegate's re-grant). + * + * Returns whether a grant was actually removed. Matching nothing isn't an + * error (an owner has no grant row to delete), but callers must be able to + * tell rather than reporting a removal that didn't happen. + */ async revokeUserUserPermission( actor: Actor, username: string, permission: string, meta: GrantMeta = {}, - ): Promise { + opts: { issuerUserId?: number } = {}, + ): Promise { permission = await this.rewritePermission(permission); const user = await this.stores.user.getByUsername(username); if (!user) @@ -884,18 +904,24 @@ export class PermissionService extends PuterService { legacyCode: 'subject_does_not_exist', }); - if (!(await this.canManagePermission(actor, permission))) { - throw new HttpError(403, `permission_denied: ${permission}`, { - legacyCode: 'permission_denied', - }); - } if (!actor.user?.id) throw new HttpError(403, 'actor must be a user', { legacyCode: 'forbidden', }); const issuerId = actor.user.id; - await this.stores.permission.delFlatUserPerm(user.id, permission); + // Giving up access you hold needs no authority over the permission — + // it can only ever narrow what you can reach. + const isSelfRevoke = user.id === issuerId; + if ( + !isSelfRevoke && + !(await this.canManagePermission(actor, permission)) + ) { + throw new HttpError(403, `permission_denied: ${permission}`, { + legacyCode: 'permission_denied', + }); + } + // Awaited (unlike the grant-path upsert): the generation bump below // guarantees the holder's very next check re-derives from SQL, so a // fire-and-forget delete here could lose the race and let that scan @@ -903,22 +929,53 @@ export class PermissionService extends PuterService { // caller gets the error — the permission is then still effectively // granted (flat falls back to the surviving SQL row), which is the // consistent, retryable outcome. - await this.stores.permission.deleteUserUserPermByHolder( + const revoked = await this.stores.permission.deleteUserUserPermByHolder( user.id, permission, + opts.issuerUserId ?? issuerId, ); - this.stores.permission - .auditUserUserPerm({ - holder_user_id: user.id, - issuer_user_id: issuerId, - permission, - action: 'revoke', - reason: meta.reason ?? 'revoked via PermissionService', - }) - .catch(() => {}); - // The holder loses access on their next check, not after the TTL. + // The flat key isn't issuer-scoped, so it may only go once no issuer + // grants this any more. Dropping it while another grant stands would + // cut access outright for a `manage:`-only issuer, whose grant the + // linked chain can't resolve. + // + // Must read the primary: the delete above just landed there, and a + // replica (or the row cache the plain read would re-warm from it) can + // still show the deleted row. Skipping the flat delete on that stale + // view leaves a no-TTL flat grant standing with no SQL rows behind + // it — permanent, invisible access. + const remaining = + await this.stores.permission.readLinkedUserUserPermsFromPrimary( + user.id, + [permission], + ); + if (remaining.length === 0) { + await this.stores.permission.delFlatUserPerm(user.id, permission); + } + + // Only record a revoke that happened. + if (revoked) { + this.stores.permission + .auditUserUserPerm({ + holder_user_id: user.id, + issuer_user_id: issuerId, + permission, + action: 'revoke', + reason: meta.reason ?? 'revoked via PermissionService', + }) + .catch((err) => { + console.warn( + '[PermissionService] failed to audit user-user revoke:', + err, + ); + }); + } + + // Unconditional: the flat delete above can't report what it removed, so + // skipping the bump on a no-op risks leaving a cached allow standing. if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid); + return revoked; } /** @@ -1293,26 +1350,6 @@ export class PermissionService extends PuterService { // -- Issuer queries (share discovery et al) ----------------------- - async listUserPermissionIssuers(user: { - id: number; - }): Promise> { - const ids = await this.stores.permission.listUserPermissionIssuerIds( - user.id, - ); - const usersById = await this.stores.user.getByIds(ids); - return ids.map((id) => { - const u = usersById.get(id); - return u - ? { - id: u.id, - uuid: u.uuid, - username: u.username, - email: u.email, - } - : null; - }); - } - async queryIssuerPermissionsByPrefix( issuer: { id: number }, prefix: string, diff --git a/src/backend/services/share/ShareConsistency.test.ts b/src/backend/services/share/ShareConsistency.test.ts new file mode 100644 index 0000000000..f127021382 --- /dev/null +++ b/src/backend/services/share/ShareConsistency.test.ts @@ -0,0 +1,978 @@ +/* + * 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 . + */ + +/** + * Cross-store consistency for sharing. + * + * A share is three writes in two stores plus a cache: the flat KV grant (the + * terminal answer), the SQL delegation row, and the SQL share index, with a + * Redis generation counter invalidating the scan cache. Every consistency bug + * in this feature lives in the gaps between them, so each case here snapshots + * all four around the action and asserts the invariants from + * FILE-SHARING-TEST-PLAN.md §1. + * + * Set SHARE_REPORT= to write the before/after tables out as markdown. + */ + +import { writeFileSync } from 'node:fs'; +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterServer } from '../../server.js'; +import { createTestUser, setupTestServer } from '../../testUtil.js'; +import { PermissionUtil } from '../permission/permissionUtil'; + +const MODES = ['see', 'list', 'read', 'write'] as const; + +interface Party { + label: string; + id: number; + uuid: string; + username: string; + actor: Actor; + email: string; +} + +interface Snapshot { + kv: Record; + sqlPerms: Record; + sqlIndex: Record; + redisGen: Record; + canRead: Record; +} + +const report: string[] = []; + +describe('share consistency across KV, SQL and Redis', () => { + let server: PuterServer; + + beforeAll(async () => { + server = await setupTestServer(); + }); + + afterAll(async () => { + const out = process.env.SHARE_REPORT; + if (out) writeFileSync(out, report.join('\n')); + await server?.shutdown(); + }); + + const makeParty = async (label: string): Promise => { + const username = `p${label.toLowerCase()}${Math.random().toString(36).slice(2, 8)}`; + await createTestUser(server, { username, password: 'pw-test-1234' }); + const found = await server.stores.user.getByUsername(username); + if (!found) throw new Error('test user missing'); + const email = `${username}@test.local`; + await server.stores.user.update(found.id, { + email, + email_confirmed: true, + }); + const user = await server.stores.user.getById(found.id, { + force: true, + }); + return { + label, + id: user!.id, + uuid: user!.uuid, + username, + email, + actor: { user: user as Actor['user'], effectiveApp: null }, + }; + }; + + const makeEntry = async (owner: Party, isDir = false, parent?: string) => { + const uuid = uuidv4(); + const name = `${isDir ? 'd' : 'f'}-${uuid.slice(0, 8)}${isDir ? '' : '.txt'}`; + const base = parent ?? `/${owner.username}`; + const path = `${base}/${name}`; + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, ?, ?)', + [ + uuid, + name, + path, + owner.id, + isDir ? 1 : 0, + Math.floor(Date.now() / 1000), + ], + ); + const entry = await server.stores.fsEntry.getEntryByPath(path); + if (!entry) throw new Error('fsentry not created'); + return entry; + }; + + const canRead = (actor: Actor, path: string) => + server.services.acl.check( + actor, + { + path, + resolveAncestors: () => + server.services.fs.getAncestorChain(path), + }, + 'read', + ); + + /** All four layers, for one entry and a fixed cast. */ + const snapshot = async ( + parties: Party[], + entry: { id: number; uuid: string; path: string }, + ): Promise => { + const kv: Record = {}; + for (const p of parties) { + for (const mode of [ + ...MODES.map((m) => `fs:${entry.uuid}:${m}`), + `manage:fs:${entry.uuid}`, + ]) { + const key = PermissionUtil.join('perm', String(p.id), mode); + const got = await server.stores.kv.get({ key }); + const val = (got as { res?: unknown })?.res ?? got; + if (val !== null && val !== undefined) { + const short = mode.startsWith('manage:') + ? 'manage' + : mode.split(':').pop()!; + // A flat key is either an authoritative grant (`deleted` + // present, no expiry) or a 60s warm-cache entry derived + // from the SQL scan. Only the first is a share. + const v = val as { deleted?: boolean; data?: unknown }; + kv[`${p.label}:${short}`] = + v.deleted === undefined + ? 'warm' + : v.deleted + ? 'tombstone' + : 'grant'; + } + } + } + + const permRows = (await server.clients.db.read( + 'SELECT holder_user_id, issuer_user_id, permission FROM `user_to_user_permissions` WHERE `permission` LIKE ?', + [`%${entry.uuid}%`], + )) as Array<{ + holder_user_id: number; + issuer_user_id: number; + permission: string; + }>; + const byId = new Map(parties.map((p) => [p.id, p.label])); + const sqlPerms: Record = {}; + for (const r of permRows) { + const mode = r.permission.startsWith('manage:') + ? 'manage' + : r.permission.split(':').pop()!; + sqlPerms[ + `${byId.get(r.holder_user_id) ?? r.holder_user_id}:${mode}` + ] = `from ${byId.get(r.issuer_user_id) ?? r.issuer_user_id}`; + } + + const indexRows = (await server.clients.db.read( + 'SELECT holder_user_id, issuer_user_id, mode FROM `share` WHERE `fsentry_id` = ?', + [entry.id], + )) as Array<{ + holder_user_id: number; + issuer_user_id: number; + mode: string; + }>; + const sqlIndex: Record = {}; + for (const r of indexRows) { + sqlIndex[`${byId.get(r.holder_user_id) ?? r.holder_user_id}`] = + `${r.mode} from ${byId.get(r.issuer_user_id) ?? r.issuer_user_id}`; + } + + const redisGen: Record = {}; + for (const p of parties) { + redisGen[p.label] = + await server.stores.permission.getCacheGeneration( + `user:${p.uuid}`, + ); + } + + const reads: Record = {}; + for (const p of parties) + reads[p.label] = await canRead(p.actor, entry.path); + + return { kv, sqlPerms, sqlIndex, redisGen, canRead: reads }; + }; + + const fmt = (s: Snapshot) => ({ + 'KV (flat grants)': Object.keys(s.kv).length + ? JSON.stringify(s.kv) + : '—', + 'SQL user_to_user_permissions': Object.keys(s.sqlPerms).length + ? JSON.stringify(s.sqlPerms) + : '—', + 'SQL share (index)': Object.keys(s.sqlIndex).length + ? JSON.stringify(s.sqlIndex) + : '—', + 'Redis cachegen': JSON.stringify(s.redisGen), + 'acl.check(read)': JSON.stringify(s.canRead), + }); + + const record = ( + title: string, + note: string, + before: Snapshot, + after: Snapshot, + ) => { + const b = fmt(before); + const a = fmt(after); + report.push( + `\n### ${title}\n`, + note, + '', + '| Layer | Before | After |', + '| --- | --- | --- |', + ); + for (const k of Object.keys(b)) { + report.push( + `| ${k} | \`${b[k as keyof typeof b]}\` | \`${a[k as keyof typeof a]}\` |`, + ); + } + }; + + /** + * I1/I3/I5 from the plan, checked against whatever the stores currently + * hold. Every case ends with this — a scenario that leaves the layers + * disagreeing is a failure even when its own assertions pass. + */ + const assertInvariants = async ( + parties: Party[], + entry: { id: number; uuid: string }, + owner: Party, + ) => { + const indexRows = (await server.clients.db.read( + 'SELECT holder_user_id, issuer_user_id, mode FROM `share` WHERE `fsentry_id` = ?', + [entry.id], + )) as Array<{ + holder_user_id: number; + issuer_user_id: number; + mode: string; + }>; + + for (const row of indexRows) { + // I5 — the owner's access comes from ownership, never a grant row. + expect(row.holder_user_id).not.toBe(owner.id); + + // I1 — an index row must have both a KV grant and a SQL row behind it. + const perm = + row.mode === 'manage' + ? `manage:fs:${entry.uuid}` + : `fs:${entry.uuid}:${row.mode}`; + const key = PermissionUtil.join( + 'perm', + String(row.holder_user_id), + perm, + ); + const kvVal = await server.stores.kv.get({ key }); + expect((kvVal as { res?: unknown })?.res ?? kvVal).toBeTruthy(); + + const sql = (await server.clients.db.read( + 'SELECT permission FROM `user_to_user_permissions` WHERE `holder_user_id` = ? AND `issuer_user_id` = ? AND `permission` = ?', + [row.holder_user_id, row.issuer_user_id, perm], + )) as unknown[]; + expect(sql.length).toBeGreaterThan(0); + } + + // I6 — never two live *granted* modes for the same (holder, entry). + // Warm-cache entries are excluded: the scan derives implied modes from + // the granted one, so `write` legitimately warms `read` alongside it. + for (const p of parties) { + const granted = [] as string[]; + for (const mode of MODES) { + const key = PermissionUtil.join( + 'perm', + String(p.id), + `fs:${entry.uuid}:${mode}`, + ); + const got = await server.stores.kv.get({ key }); + const val = (got as { res?: unknown })?.res ?? got; + if ((val as { deleted?: boolean })?.deleted === false) + granted.push(mode); + } + expect(granted.length).toBeLessThanOrEqual(1); + } + }; + + const share = (actor: Actor, input: Record) => + runWithContext({ actor }, () => + server.services.share.share(actor, input as never), + ); + const unshare = (actor: Actor, input: Record) => + runWithContext({ actor }, () => + server.services.share.unshare(actor, input as never), + ); + + describe('granting access', () => { + it('writes the grant, the row and the index together', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + const cast = [A, B]; + + const before = await snapshot(cast, entry); + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + const after = await snapshot(cast, entry); + + record( + 'A shares a file with B, read-only', + "The baseline. All three stores must gain the grant, and B's cache generation must move so any cached deny is retired.", + before, + after, + ); + + expect(before.canRead.B).toBe(false); + expect(after.canRead.B).toBe(true); + expect(after.kv['B:read']).toBe('grant'); + expect(after.sqlPerms['B:read']).toBeDefined(); + expect(after.sqlIndex.B).toContain('read'); + expect(after.redisGen.B).toBeGreaterThan(before.redisGen.B); + await assertInvariants(cast, entry, A); + }); + + it('replaces a mode rather than stacking a second grant', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + const cast = [A, B]; + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + const before = await snapshot(cast, entry); + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'write', + }); + const after = await snapshot(cast, entry); + + record( + 'A raises B from read to write', + 'I6: the superseded mode must be gone from KV, not merely shadowed. A leaked second key is silent privilege retention.', + before, + after, + ); + + expect(before.kv['B:read']).toBe('grant'); + // The granted read is gone; a warm entry may remain because `write` + // implies `read` and the scan caches what it derived. + expect(after.kv['B:read']).not.toBe('grant'); + expect(after.kv['B:write']).toBe('grant'); + expect(Object.keys(after.sqlIndex)).toHaveLength(1); + await assertInvariants(cast, entry, A); + }); + + it("records a delegate's re-share where the owner can see it", async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const C = await makeParty('C'); + const entry = await makeEntry(A); + const cast = [A, B, C]; + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'manage', + }); + const before = await snapshot(cast, entry); + await share(B.actor, { + uid: entry.uuid, + recipient: { email: C.email }, + mode: 'read', + }); + const after = await snapshot(cast, entry); + + record( + 'B, who manages, re-shares with C', + 'The permission tables are keyed issuer→holder, so the index is the only thing that can show A what B did.', + before, + after, + ); + + expect(after.canRead.C).toBe(true); + expect(after.sqlIndex.C).toBe('read from B'); + await assertInvariants(cast, entry, A); + }); + }); + + describe('withdrawing access', () => { + it('takes the re-shares of a revoked delegate with it', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const C = await makeParty('C'); + const entry = await makeEntry(A); + const cast = [A, B, C]; + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'manage', + }); + await share(B.actor, { + uid: entry.uuid, + recipient: { email: C.email }, + mode: 'read', + }); + const before = await snapshot(cast, entry); + const res = await unshare(A.actor, { + uid: entry.uuid, + recipient: { username: B.username }, + }); + const after = await snapshot(cast, entry); + + record( + 'A revokes B, whose grant was all C had', + `C's authority derived from B's, so it cannot outlive it. Reported \`revoked: ${res.revoked}\`.`, + before, + after, + ); + + expect(after.canRead.B).toBe(false); + expect(after.canRead.C).toBe(false); + expect(Object.keys(after.sqlIndex)).toHaveLength(0); + expect(after.kv['C:read']).not.toBe('grant'); + await assertInvariants(cast, entry, A); + }); + + it('takes the re-shares of a delegate who leaves', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const C = await makeParty('C'); + const entry = await makeEntry(A); + const cast = [A, B, C]; + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'manage', + }); + await share(B.actor, { + uid: entry.uuid, + recipient: { email: C.email }, + mode: 'read', + }); + const before = await snapshot(cast, entry); + await unshare(B.actor, { + uid: entry.uuid, + recipient: { username: B.username }, + }); + const after = await snapshot(cast, entry); + + record( + 'B leaves the share themselves', + "Regression guard. Cascading after clearing B's own grant would strip the authority the cascade needs, orphaning C with a live grant and no index row — invisible and unrevocable.", + before, + after, + ); + + expect(after.canRead.B).toBe(false); + expect(after.canRead.C).toBe(false); + expect(Object.keys(after.sqlIndex)).toHaveLength(0); + expect(Object.keys(after.sqlPerms)).toHaveLength(0); + await assertInvariants(cast, entry, A); + }); + + it('refuses to revoke the owner, and disturbs nothing else', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + const cast = [A, B]; + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + const before = await snapshot(cast, entry); + await expect( + unshare(A.actor, { + uid: entry.uuid, + recipient: { username: A.username }, + }), + ).rejects.toMatchObject({ statusCode: 400 }); + const after = await snapshot(cast, entry); + + record( + 'Someone tries to revoke the owner', + 'Ownership resolves through the `is-owner` implicator at scan time, not a grant row, so there is nothing to revoke. The refusal must not disturb the unrelated share to B.', + before, + after, + ); + + expect(after.canRead.A).toBe(true); + expect(after.sqlIndex).toEqual(before.sqlIndex); + expect(after.kv).toEqual(before.kv); + await assertInvariants(cast, entry, A); + }); + }); + + describe('access reached through a folder', () => { + it('leaves no row on a file reached through its folder', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const dir = await makeEntry(A, true); + const child = await makeEntry(A, false, dir.path); + const cast = [A, B]; + + const before = await snapshot(cast, child); + await share(A.actor, { + uid: dir.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + const after = await snapshot(cast, child); + + record( + 'A shares a folder, and B reaches a file inside it', + 'The child gains no row in any store — access is inherited. `getShares` on the child must still report B, or the owner is told nobody can reach a file that someone can.', + before, + after, + ); + + expect(after.canRead.B).toBe(true); + expect(Object.keys(after.sqlIndex)).toHaveLength(0); + expect(after.kv['B:read']).not.toBe('grant'); + + const shares = await server.services.share.listSharesOf(A.actor, { + uid: child.uuid, + }); + const row = shares.find((s) => s.holder.username === B.username); + expect(row?.inheritedFrom).toBe(dir.path); + await assertInvariants(cast, child, A); + }); + }); + + describe('access outliving its source', () => { + it('retires every grant when the entry is deleted', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + const cast = [A, B]; + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + const before = await snapshot(cast, entry); + await server.services.share.onEntryDeleted(entry.uuid); + await server.clients.db.write( + 'DELETE FROM `fsentries` WHERE `uuid` = ?', + [entry.uuid], + ); + const after = await snapshot(cast, entry); + + record( + 'The shared file is deleted', + 'I3: no grant may outlive its entry. The FK cascade covers the index row; the permission has to be retired explicitly.', + before, + after, + ); + + expect( + Object.values(after.kv).filter((v) => v === 'grant'), + ).toHaveLength(0); + expect(Object.keys(after.sqlPerms)).toHaveLength(0); + expect(after.canRead.B).toBe(false); + }); + + it('stops answering "allowed" once the entry is gone', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + expect(await canRead(B.actor, entry.path)).toBe(true); + + await server.services.share.onEntryDeleted(entry.uuid); + await server.clients.db.write( + 'DELETE FROM `fsentries` WHERE `uuid` = ?', + [entry.uuid], + ); + + const sql = (await server.clients.db.read( + 'SELECT permission FROM `user_to_user_permissions` WHERE `permission` LIKE ?', + [`%${entry.uuid}%`], + )) as unknown[]; + const key = PermissionUtil.join( + 'perm', + String(B.id), + `fs:${entry.uuid}:read`, + ); + const kvRaw = await server.stores.kv.get({ key }); + const kv = ((kvRaw as { res?: unknown })?.res ?? null) as unknown; + + console.log('[S7b] sql rows after delete:', sql.length); + console.log('[S7b] kv value after delete:', JSON.stringify(kv)); + console.log( + '[S7b] acl.check(read) after delete:', + await canRead(B.actor, entry.path), + ); + + expect(sql).toHaveLength(0); + expect(kv).toBeNull(); + + // Both stores being clean is not enough — a holder's cached scan keeps + // answering "allowed" until its generation moves. + expect(await canRead(B.actor, entry.path)).toBe(false); + }); + }); + + describe('a caller who cannot see the item', () => { + it('tells a stranger nothing and writes nothing', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + const cast = [A, B]; + + const before = await snapshot(cast, entry); + await expect( + share(B.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 404 }); + const after = await snapshot(cast, entry); + + record( + 'B, who cannot see the file, tries to share it', + '404 rather than 403: a 403 would confirm the uuid exists to anyone who guesses one. No store may be touched.', + before, + after, + ); + + expect(after.kv).toEqual(before.kv); + expect(after.sqlPerms).toEqual(before.sqlPerms); + expect(after.sqlIndex).toEqual(before.sqlIndex); + }); + }); + + describe('under concurrency', () => { + it('leaves exactly one grant when modes race', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + const cast = [A, B]; + + const modes = [ + 'see', + 'list', + 'read', + 'write', + 'read', + 'write', + 'list', + 'see', + ]; + const settled = await Promise.allSettled( + modes.map((mode) => + share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode, + }), + ), + ); + expect(settled.some((r) => r.status === 'fulfilled')).toBe(true); + + const after = await snapshot(cast, entry); + // I6 is what `#withNodeLock` exists to protect: whichever write + // lands last, the holder must not end up with two live grants. + const grants = Object.entries(after.kv).filter( + ([k, v]) => k.startsWith('B:') && v === 'grant', + ); + expect(grants.length).toBeLessThanOrEqual(1); + expect(Object.keys(after.sqlIndex)).toHaveLength(1); + await assertInvariants(cast, entry, A); + }); + + it('settles a racing share and unshare to one answer', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const entry = await makeEntry(A); + const cast = [A, B]; + + await share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + await Promise.allSettled([ + share(A.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'write', + }), + unshare(A.actor, { + uid: entry.uuid, + recipient: { username: B.username }, + }), + ]); + + // Either outcome is legitimate; "revoked but the KV grant remains" + // is not. The stores and the effective answer must agree. + const after = await snapshot(cast, entry); + const hasGrant = Object.entries(after.kv).some( + ([k, v]) => k.startsWith('B:') && v === 'grant', + ); + expect(hasGrant).toBe(after.canRead.B); + expect(Object.keys(after.sqlIndex).length > 0).toBe(hasGrant); + await assertInvariants(cast, entry, A); + }); + + it('lands every recipient when many share one entry', async () => { + const A = await makeParty('A'); + const entry = await makeEntry(A); + const recipients = await Promise.all( + Array.from({ length: 8 }, (_, i) => makeParty(`R${i}`)), + ); + + const settled = await Promise.allSettled( + recipients.map((r) => + share(A.actor, { + uid: entry.uuid, + recipient: { email: r.email }, + mode: 'read', + }), + ), + ); + expect( + settled.filter((r) => r.status === 'fulfilled'), + ).toHaveLength(8); + + // The lock is per (holder, entry), so distinct holders must not + // contend — every one of them ends up with access. + for (const r of recipients) { + expect(await canRead(r.actor, entry.path)).toBe(true); + } + await assertInvariants([A, ...recipients], entry, A); + }); + + it('terminates on a delegation cycle', async () => { + const A = await makeParty('A'); + const B = await makeParty('B'); + const C = await makeParty('C'); + const entry = await makeEntry(A); + + // Only the owner can hand out `manage`, so the cycle is built from + // two peers who then grant each other plain access. + for (const p of [B, C]) { + await share(A.actor, { + uid: entry.uuid, + recipient: { email: p.email }, + mode: 'manage', + }); + } + await share(B.actor, { + uid: entry.uuid, + recipient: { email: C.email }, + mode: 'read', + }); + await share(C.actor, { + uid: entry.uuid, + recipient: { email: B.email }, + mode: 'read', + }); + + // The `seen` guard is the only thing keeping this from recursing + // forever; a hang here is the failure. + await unshare(A.actor, { + uid: entry.uuid, + recipient: { username: B.username }, + }); + expect(await canRead(B.actor, entry.path)).toBe(false); + await assertInvariants([A, B, C], entry, A); + }); + + it('holds the day budget however many requests race', async () => { + const A = await makeParty('A'); + const limit = 3; + const concurrency = 12; + const recipients = await Promise.all( + Array.from({ length: concurrency }, (_, i) => + makeParty(`Q${i}`), + ), + ); + const entry = await makeEntry(A); + + const cfg = ( + server.services.share as unknown as { + config: { share_daily_limit?: number }; + } + ).config; + const original = cfg.share_daily_limit; + cfg.share_daily_limit = limit; + let granted = 0; + try { + const settled = await Promise.allSettled( + recipients.map((r) => + share(A.actor, { + uid: entry.uuid, + recipient: { email: r.email }, + mode: 'read', + }), + ), + ); + granted = settled.filter( + (r) => r.status === 'fulfilled', + ).length; + } finally { + cfg.share_daily_limit = original; + } + + // The reservation is atomic, so concurrency cannot widen the + // budget: exactly `limit` shares land however many race for them. + expect(granted).toBe(limit); + report.push( + `\n_Daily budget: limit ${limit}, ${concurrency} concurrent → ${granted} granted (exactly the limit)._`, + ); + }); + }); + + describe('cost', () => { + /** A chain of nested directories, deepest last. */ + const makeChain = async (owner: Party, depth: number) => { + const dirs = []; + let parent = `/${owner.username}`; + for (let i = 0; i < depth; i++) { + const dir = await makeEntry(owner, true, parent); + dirs.push(dir); + parent = dir.path; + } + const leaf = await makeEntry(owner, false, parent); + return { dirs, leaf }; + }; + + /** + * Round trips, not milliseconds. Local SQLite answers in ~0ms, so wall + * clock hides an N+1 that costs 2ms a hop against a real database. + */ + const countQueries = async (fn: () => Promise) => { + const db = server.clients.db as unknown as { + read: (...a: unknown[]) => Promise; + }; + const original = db.read.bind(db); + let n = 0; + db.read = (...args: unknown[]) => { + n++; + return original(...args); + }; + try { + await fn(); + } finally { + db.read = original; + } + return n; + }; + + it('reads one row per ancestor when listing shares', async () => { + const rows: string[] = []; + for (const depth of [1, 4, 8, 12]) { + const A = await makeParty('A'); + const B = await makeParty('B'); + const { dirs, leaf } = await makeChain(A, depth); + await share(A.actor, { + uid: dirs[0].uuid, + recipient: { email: B.email }, + mode: 'manage', + }); + + const listQ = await countQueries(() => + server.services.share.listSharesOf(A.actor, { + uid: leaf.uuid, + }), + ); + + // The delegate's manage on the leaf resolves by walking up to + // the shared root — the cost this implicator introduced. + let canManage = false; + const manageQ = await countQueries(async () => { + canManage = + await server.services.permission.canManagePermission( + B.actor, + `fs:${leaf.uuid}:read`, + ); + }); + expect(canManage).toBe(true); + + rows.push(`| ${depth} | ${listQ} | ${manageQ} |`); + } + report.push( + '\n### Cost against path depth\n', + '| depth | listSharesOf queries | manage-walk queries |', + '| --- | --- | --- |', + ...rows, + ); + }); + + it('scales a cascade with the number of descendant shares', async () => { + const rows: string[] = []; + for (const width of [1, 5, 15]) { + const A = await makeParty('A'); + const B = await makeParty('B'); + const dir = await makeEntry(A, true); + await share(A.actor, { + uid: dir.uuid, + recipient: { email: B.email }, + mode: 'manage', + }); + + const holders = await Promise.all( + Array.from({ length: width }, (_, i) => makeParty(`H${i}`)), + ); + for (const h of holders) { + const child = await makeEntry(A, false, dir.path); + await share(B.actor, { + uid: child.uuid, + recipient: { email: h.email }, + mode: 'read', + }); + } + + let res = { revoked: 0 }; + const q = await countQueries(async () => { + res = await unshare(A.actor, { + uid: dir.uuid, + recipient: { username: B.username }, + }); + }); + rows.push(`| ${width} | ${q} | ${res.revoked} |`); + + for (const h of holders) { + expect(await canRead(h.actor, dir.path)).toBe(false); + } + } + report.push( + '\n### Cost of a cascade against descendant count\n', + '| descendant shares | unshare queries | revoked |', + '| --- | --- | --- |', + ...rows, + ); + }); + }); +}); diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts new file mode 100644 index 0000000000..975e12e012 --- /dev/null +++ b/src/backend/services/share/ShareService.test.ts @@ -0,0 +1,1449 @@ +/* + * 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 . + */ + +import { v4 as uuidv4 } from 'uuid'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterServer } from '../../server.js'; +import { createTestUser, setupTestServer } from '../../testUtil.js'; + +describe('ShareService', () => { + let server: PuterServer; + + beforeAll(async () => { + server = await setupTestServer(); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + const makeUser = async () => { + const username = `sh${Math.random().toString(36).slice(2, 9)}`; + await createTestUser(server, { username, password: 'pw-test-1234' }); + const user = await server.stores.user.getByUsername(username); + if (!user) throw new Error('test user missing'); + const email = `${username}@test.local`; + await server.stores.user.update(user.id, { + email, + email_confirmed: true, + }); + const fresh = await server.stores.user.getById(user.id, { + force: true, + }); + const actor: Actor = { + user: fresh as Actor['user'], + effectiveApp: null, + }; + return { user: fresh!, actor, email }; + }; + + /** A real fsentry under the user's home, so ancestor chains resolve. */ + const makeFile = async (owner: { id: number; username: string }) => { + const uuid = uuidv4(); + const name = `f-${uuid.slice(0, 8)}.txt`; + const path = `/${owner.username}/${name}`; + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)', + [uuid, name, path, owner.id, Math.floor(Date.now() / 1000)], + ); + const entry = await server.stores.fsEntry.getEntryByPath(path); + if (!entry) throw new Error('fsentry not created'); + return entry; + }; + + /** A directory and a file inside it, so the file inherits the folder's shares. */ + const makeDirWithFile = async (owner: { id: number; username: string }) => { + const dirUuid = uuidv4(); + const dirName = `d-${dirUuid.slice(0, 8)}`; + const dirPath = `/${owner.username}/${dirName}`; + const fileUuid = uuidv4(); + const fileName = `f-${fileUuid.slice(0, 8)}.txt`; + const now = Math.floor(Date.now() / 1000); + + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 1, ?)', + [dirUuid, dirName, dirPath, owner.id, now], + ); + const dirRow = await server.stores.fsEntry.getEntryByPath(dirPath); + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)', + [ + fileUuid, + fileName, + `${dirPath}/${fileName}`, + owner.id, + now, + dirRow!.id, + dirUuid, + ], + ); + + const dir = await server.stores.fsEntry.getEntryByPath(dirPath); + const file = await server.stores.fsEntry.getEntryByPath( + `${dirPath}/${fileName}`, + ); + if (!dir || !file) throw new Error('fsentries not created'); + return { dir, file }; + }; + + const canRead = async (actor: Actor, path: string) => + server.services.acl.check( + actor, + { + path, + resolveAncestors: () => server.services.fs.getAncestorChain(path), + }, + 'read', + ); + + const share = (actor: Actor, input: Record) => + runWithContext({ actor }, () => + server.services.share.share(actor, input as never), + ); + + const unshare = (actor: Actor, input: Record) => + runWithContext({ actor }, () => + server.services.share.unshare(actor, input as never), + ); + + it('grants access and indexes the share', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + expect(await canRead(recipient.actor, file.path)).toBe(false); + + const result = await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + expect(result.mode).toBe('read'); + expect(result.path).toBe(file.path); + expect(await canRead(recipient.actor, file.path)).toBe(true); + + const listed = await server.services.share.listSharedWithMe( + recipient.actor, + ); + expect(listed.items.map((i) => i.entryUid)).toContain(file.uuid); + }); + + it('carries the entry metadata the file browser renders', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const [listed] = ( + await server.services.share.listSharedWithMe(recipient.actor) + ).items; + expect(listed.modified).toBe(file.modified); + expect(Number.isFinite(listed.modified)).toBe(true); + expect(listed.size).toBe(file.size); + }); + + it('resolves a recipient by username as well as email', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('refuses to share with yourself or with the owner', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: owner.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('refuses an unknown mode and an unknown recipient', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: 'nobody@nowhere.test' }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 404 }); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: 'nobody@nowhere.test' }, + mode: 'wizard', + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('does not resolve an email its account has not confirmed', async () => { + const owner = await makeUser(); + const squatter = await makeUser(); + await server.stores.user.update(squatter.user.id, { + email_confirmed: false, + }); + const file = await makeFile(owner.user); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: squatter.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ + statusCode: 404, + legacyCode: 'user_does_not_exist', + }); + + // A username names exactly one account, confirmed or not. + await share(owner.actor, { + uid: file.uuid, + recipient: { username: squatter.user.username }, + mode: 'read', + }); + }); + + it('hides a file from a stranger trying to share it', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + // 404 rather than 403 — a failed share must not confirm the file + // exists to someone who cannot even see it. + await expect( + share(stranger.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('tells a stranger nothing about whether a recipient account exists', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + // The recipient must resolve only after authorization: a caller who + // cannot manage the entry gets the same "no such subject" error for a + // real recipient and a made-up one, so /share cannot be used to probe + // which emails have accounts. + const probe = (email: string) => + share(stranger.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + await expect(probe(recipient.email)).rejects.toMatchObject({ + statusCode: 404, + legacyCode: 'subject_does_not_exist', + }); + await expect(probe('nobody@nowhere.test')).rejects.toMatchObject({ + statusCode: 404, + legacyCode: 'subject_does_not_exist', + }); + }); + + it('revokes access and drops the index row', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + expect(await canRead(recipient.actor, file.path)).toBe(true); + + const result = await unshare(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + }); + + expect(result.revoked).toBe(1); + expect(await canRead(recipient.actor, file.path)).toBe(false); + const listed = await server.services.share.listSharedWithMe( + recipient.actor, + ); + expect(listed.items.map((i) => i.entryUid)).not.toContain(file.uuid); + }); + + it('refuses to revoke the owner', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + + await expect( + unshare(owner.actor, { + uid: file.uuid, + recipient: { email: owner.email }, + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('shows the owner a share a manage delegate issued', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + + // The owner cannot see this through the permission tables, which are + // keyed issuer→holder; the index is what answers it. + const rows = await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }); + const holders = rows.map((r) => r.holder.username); + expect(holders).toContain(delegate.user.username); + expect(holders).toContain(third.user.username); + expect( + rows.find((r) => r.holder.username === third.user.username)?.issuer + .username, + ).toBe(delegate.user.username); + }); + + it('reports a share on a file as inherited from the folder', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // Nobody was granted the file itself, so its own rows are empty — but + // the recipient can reach it, and the owner has to be told so. + const rows = await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }); + const row = rows.find( + (r) => r.holder.username === recipient.user.username, + ); + expect(row).toBeDefined(); + expect(row?.inheritedFrom).toBe(dir.path); + expect(row?.mode).toBe('read'); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('marks a share on the item itself as not inherited', async () => { + const owner = await makeUser(); + const viaFolder = await makeUser(); + const direct = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: viaFolder.email }, + mode: 'read', + }); + await share(owner.actor, { + uid: file.uuid, + recipient: { email: direct.email }, + mode: 'write', + }); + + const rows = await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }); + expect( + rows.find((r) => r.holder.username === direct.user.username) + ?.inheritedFrom, + ).toBeNull(); + expect( + rows.find((r) => r.holder.username === viaFolder.user.username) + ?.inheritedFrom, + ).toBe(dir.path); + }); + + it('takes downstream access with a delegate who leaves', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + expect(await canRead(third.actor, file.path)).toBe(true); + + // What "Remove from Shared" calls. The delegate's grant goes, and with + // it the authority behind everything they issued. + await unshare(delegate.actor, { + uid: file.uuid, + recipient: { username: delegate.user.username }, + }); + + expect(await canRead(delegate.actor, file.path)).toBe(false); + expect(await canRead(third.actor, file.path)).toBe(false); + }); + + it('keeps the index row when the actor could not revoke anything', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + await unshare(delegate.actor, { + uid: file.uuid, + recipient: { username: delegate.user.username }, + }); + + // A grant the owner cannot see is a grant nobody can withdraw, so the + // owner's view must not go quiet while access is still live. + const rows = await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }); + const stillListed = rows.map((r) => r.holder.username); + expect(stillListed).not.toContain(third.user.username); + expect(await canRead(third.actor, file.path)).toBe(false); + }); + + describe('manage inherits down the tree', () => { + it('lets a folder delegate re-share and inspect a file inside it', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + + // Authority now reaches the child the way access already did. + const rows = await server.services.share.listSharesOf( + delegate.actor, + { uid: file.uuid }, + ); + expect(rows.map((r) => r.holder.username)).toContain( + delegate.user.username, + ); + + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + expect(await canRead(third.actor, file.path)).toBe(true); + }); + + it('revokes what a folder delegate re-shared from inside it', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + expect(await canRead(third.actor, file.path)).toBe(true); + + // The grant on the child came from authority held on the folder, + // so withdrawing that authority has to reach down to it. + await unshare(owner.actor, { + uid: dir.uuid, + recipient: { username: delegate.user.username }, + }); + + expect(await canRead(delegate.actor, file.path)).toBe(false); + expect(await canRead(third.actor, file.path)).toBe(false); + }); + + it('does not let plain access on a folder manage what is inside', async () => { + const owner = await makeUser(); + const reader = await makeUser(); + const third = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: reader.email }, + mode: 'write', + }); + + // `write` reaches the child, but managing is a separate namespace. + // 403 rather than 404 here: they can already see the file, so + // hiding it would protect nothing. + expect(await canRead(reader.actor, file.path)).toBe(true); + await expect( + share(reader.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('does not let manage on a file leak up to its folder', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + + // Inheritance runs one way; the parent is not implied by the child. + await expect( + share(delegate.actor, { + uid: dir.uuid, + recipient: { email: third.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + }); + + it('inherits a group-issued manage grant down the tree', async () => { + const owner = await makeUser(); + const member = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + const groupUid = await server.stores.group.create({ + ownerUserId: owner.user.id, + }); + const group = (await server.stores.group.getByUid(groupUid))!; + await server.stores.group.addUsers(groupUid, [member.user.username!]); + await runWithContext({ actor: owner.actor }, () => + server.services.permission.grantUserGroupPermission( + owner.actor, + { id: Number(group.id), uid: groupUid }, + `manage:fs:${dir.uuid}`, + ), + ); + + // The grant sits on the folder and reached the member through the + // group; inheritance must carry it to the file the same as a direct + // grant would. + const held = await server.services.permission.canManagePermission( + member.actor, + `fs:${file.uuid}:read`, + ); + expect(held).toBe(true); + }); + + it('does not let a delegate pass on `manage` itself', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + + // Granting `manage` needs `manage:manage:fs:`, which only the + // owner holds — so delegation is one level deep by construction. + await expect( + share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'manage', + }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('leaves a delegate alone when their authority survives another issuer', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const middle = await makeUser(); + const leaf = await makeUser(); + const file = await makeFile(owner.user); + + // `middle` manages by the owner's grant, and separately holds a plain + // read the delegate handed out. + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(owner.actor, { + uid: file.uuid, + recipient: { email: middle.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: middle.email }, + mode: 'read', + }); + await share(middle.actor, { + uid: file.uuid, + recipient: { email: leaf.email }, + mode: 'read', + }); + + await unshare(owner.actor, { + uid: file.uuid, + recipient: { username: delegate.user.username }, + }); + + // Withdrawing the delegate costs `middle` nothing it was relying on, + // so what `middle` granted must stand. + expect(await canRead(delegate.actor, file.path)).toBe(false); + expect(await canRead(middle.actor, file.path)).toBe(true); + expect(await canRead(leaf.actor, file.path)).toBe(true); + }); + + it('lets a delegate clear only what it issued', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const fourth = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + await share(owner.actor, { + uid: file.uuid, + recipient: { email: fourth.email }, + mode: 'read', + }); + + // Its own grant: cleared. + expect( + ( + await unshare(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + }) + ).revoked, + ).toBe(1); + expect(await canRead(third.actor, file.path)).toBe(false); + + // The owner's grant to someone else: untouched. + expect( + ( + await unshare(delegate.actor, { + uid: file.uuid, + recipient: { email: fourth.email }, + }) + ).revoked, + ).toBe(0); + expect(await canRead(fourth.actor, file.path)).toBe(true); + }); + + it('revoking a delegate also revokes what they re-shared', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + expect(await canRead(third.actor, file.path)).toBe(true); + + await unshare(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + }); + + // The delegate's authority to grant came from access the owner has + // now withdrawn, so what they granted cannot outlive it. + expect(await canRead(delegate.actor, file.path)).toBe(false); + expect(await canRead(third.actor, file.path)).toBe(false); + expect( + await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }), + ).toEqual([]); + }); + + it('lets the owner clear a grant a delegate issued', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { email: third.email }, + mode: 'read', + }); + + const byOwner = await unshare(owner.actor, { + uid: file.uuid, + recipient: { email: third.email }, + }); + expect(byOwner.revoked).toBe(1); + expect(await canRead(third.actor, file.path)).toBe(false); + }); + + it('lets a recipient leave a share they did not issue', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // Dropping your own access is always allowed, whoever granted it. + const left = await unshare(recipient.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + }); + expect(left.revoked).toBe(1); + expect(await canRead(recipient.actor, file.path)).toBe(false); + }); + + it('will not let leaving a share reveal a file you cannot see', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const file = await makeFile(owner.user); + + // Self-revoke skips the manage gate, so it still has to 404 here or it + // becomes an existence oracle for any uid a stranger cares to guess. + await expect( + unshare(stranger.actor, { + uid: file.uuid, + recipient: { email: stranger.email }, + }), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + describe('daily quota', () => { + const withLimit = async (limit: number, fn: () => Promise) => { + const cfg = ( + server.services.share as unknown as { + config: { share_daily_limit?: number }; + } + ).config; + const previous = cfg.share_daily_limit; + cfg.share_daily_limit = limit; + try { + await fn(); + } finally { + cfg.share_daily_limit = previous; + } + }; + + it('refuses a new share once the day budget is spent', async () => { + const owner = await makeUser(); + const first = await makeUser(); + const second = await makeUser(); + const file = await makeFile(owner.user); + + await withLimit(1, async () => { + await share(owner.actor, { + uid: file.uuid, + recipient: { email: first.email }, + mode: 'read', + }); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: second.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 429 }); + }); + }); + + it('does not spend budget on changing an existing share mode', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await withLimit(1, async () => { + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + // Same pair, new mode — reach is unchanged, so it must not + // count against the budget the first share already spent. + const upgraded = await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'write', + }); + expect(upgraded.mode).toBe('write'); + }); + }); + + it('counts creations, so revoking does not refund the slot', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const other = await makeUser(); + const file = await makeFile(owner.user); + + await withLimit(1, async () => { + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + await unshare(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + }); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: other.email }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 429 }); + }); + }); + + it('treats a non-positive limit as unlimited', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await withLimit(0, async () => { + const result = await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + expect(result.mode).toBe('read'); + }); + }); + }); + + it('moves an existing share to a new mode rather than stacking one', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'write', + }); + + const rows = await server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }); + expect(rows).toHaveLength(1); + expect(rows[0].mode).toBe('write'); + }); + + it('keeps a mode change from tearing down the share it failed on', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + expect(await canRead(recipient.actor, file.path)).toBe(true); + + // Fail the index write the way a lost connection would. + const upsert = server.stores.share.upsertActive.bind( + server.stores.share, + ); + server.stores.share.upsertActive = async () => { + throw new Error('index write failed'); + }; + try { + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'write', + }), + ).rejects.toThrow('index write failed'); + } finally { + server.stores.share.upsertActive = upsert; + } + + // The rollback may only undo reach this call created; the read the + // recipient already had is not this call's to take away. + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('settles concurrent shares of the same pair on one row', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + const results = await Promise.allSettled( + Array.from({ length: 4 }, () => + share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }), + ), + ); + expect(results.every((r) => r.status === 'fulfilled')).toBe(true); + expect(await server.stores.share.listByFsentry(file.id)).toHaveLength( + 1, + ); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('retires a manage delegate’s grant when the entry is deleted', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: delegate.user.username }, + mode: 'manage', + }); + expect(await canRead(delegate.actor, file.path)).toBe(true); + + await server.services.share.onEntryDeleted(file.uuid); + + // `manage:fs:` does not sit under the `fs:` prefix, and it + // answers every mode — leaving it behind outlives the file. + expect( + await server.stores.permission.readLinkedUserUserPerms( + delegate.user.id, + [`manage:fs:${file.uuid}`], + ), + ).toEqual([]); + }); + + it('notifies a recipient once per window, not once per re-share', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const first = await makeFile(owner.user); + const second = await makeFile(owner.user); + + const notified: number[][] = []; + const notify = server.services.notification.notify.bind( + server.services.notification, + ); + server.services.notification.notify = (async (ids: number[]) => { + notified.push(ids); + }) as never; + try { + const shared = await share(owner.actor, { + uid: first.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + await server.services.share.notifyRecipients(owner.actor, [shared]); + + // Re-sharing what they already have is not new reach, and a second + // item inside the window still doesn't earn a second interruption. + const again = await share(owner.actor, { + uid: first.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + const other = await share(owner.actor, { + uid: second.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + await server.services.share.notifyRecipients(owner.actor, [ + again, + other, + ]); + } finally { + server.services.notification.notify = notify; + } + + expect(notified).toEqual([[recipient.user.id]]); + }); + + describe('an app is bounded by what it was given', () => { + const makeApp = async (ownerUserId) => + server.stores.app.create( + { + name: `share-app-${uuidv4()}`, + title: 'Share app', + index_url: `https://share-${uuidv4()}.test/`, + }, + { ownerUserId }, + ); + + const asApp = (owner, app) => ({ + user: owner.user, + app: { uid: app.uid, id: app.id }, + }); + + /** A real entry under the app's own AppData for `owner`. */ + const makeAppDataFile = async (owner, app) => { + const now = Math.floor(Date.now() / 1000); + let parentId = null; + let parentUid = null; + let dirPath = `/${owner.user.username}`; + for (const segment of ['AppData', app.uid]) { + dirPath = `${dirPath}/${segment}`; + const uuid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 1, ?, ?, ?)', + [uuid, segment, dirPath, owner.user.id, now, parentId, parentUid], + ); + const row = await server.stores.fsEntry.getEntryByPath(dirPath); + parentId = row.id; + parentUid = row.uuid; + } + const uuid = uuidv4(); + const filePath = `${dirPath}/state.json`; + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)', + [uuid, 'state.json', filePath, owner.user.id, now, parentId, parentUid], + ); + return server.stores.fsEntry.getEntryByPath(filePath); + }; + + it('shares a file in its own AppData without any extra grant', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeAppDataFile(owner, app); + + const result = await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + expect(result.mode).toBe('read'); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('refuses a file of its user’s that it was never given', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeFile(owner.user); + + // The user owns it and could share it themselves; the app cannot, + // because the file was never handed to the app. + await expect( + share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 404 }); + expect(await canRead(recipient.actor, file.path)).toBe(false); + }); + + it('shares a file it was specifically granted', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeFile(owner.user); + + await runWithContext({ actor: owner.actor }, () => + server.services.permission.grantUserAppPermission( + owner.actor, + app.uid, + `fs:${file.uuid}:read`, + ), + ); + + const result = await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + expect(result.mode).toBe('read'); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + // 403 rather than 404: the app can see the file, so there is no + // existence to protect — only the wider mode is refused. + it('cannot hand out more than it holds', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeFile(owner.user); + + await runWithContext({ actor: owner.actor }, () => + server.services.permission.grantUserAppPermission( + owner.actor, + app.uid, + `fs:${file.uuid}:read`, + ), + ); + + await expect( + share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'write', + }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('records which app asked, so the owner can tell', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeAppDataFile(owner, app); + + await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + const shares = await runWithContext({ actor: owner.actor }, () => + server.services.share.listSharesOf(owner.actor, { + uid: file.uuid, + }), + ); + expect(shares[0].issuedByApp).toBe(app.uid); + }); + + it('lists only the shares it can reach in shared-with-me', async () => { + const owner = await makeUser(); + const holder = await makeUser(); + const app = await makeApp(holder.user.id); + const reachable = await makeFile(owner.user); + const hidden = await makeFile(owner.user); + + for (const file of [reachable, hidden]) { + await share(owner.actor, { + uid: file.uuid, + recipient: { username: holder.user.username }, + mode: 'read', + }); + } + await runWithContext({ actor: holder.actor }, () => + server.services.permission.grantUserAppPermission( + holder.actor, + app.uid, + `fs:${reachable.uuid}:read`, + ), + ); + + const asHolder = await server.services.share.listSharedWithMe( + holder.actor, + ); + expect(asHolder.items.map((i) => i.entryUid)).toEqual( + expect.arrayContaining([reachable.uuid, hidden.uuid]), + ); + + // The app sees only the one its user handed it. + const asAppActor = await server.services.share.listSharedWithMe( + asApp(holder, app), + ); + const listed = asAppActor.items.map((i) => i.entryUid); + expect(listed).toContain(reachable.uuid); + expect(listed).not.toContain(hidden.uuid); + }); + }); + + it('retires grants when the entry is deleted', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + await server.services.share.onEntryDeleted(file.uuid); + await server.clients.db.write( + 'DELETE FROM `fsentries` WHERE `uuid` = ?', + [file.uuid], + ); + + const listed = await server.services.share.listSharedWithMe( + recipient.actor, + ); + expect(listed.items.map((i) => i.entryUid)).not.toContain(file.uuid); + const rows = await server.stores.permission.readLinkedUserUserPerms( + recipient.user.id, + [`fs:${file.uuid}:read`], + ); + expect(rows).toEqual([]); + }); + + it('retires grants when the file is removed through the FS', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + expect(await canRead(recipient.actor, file.path)).toBe(true); + + // The real delete path, not the hook. FSService emits without + // awaiting, so the cleanup lands shortly after `remove` resolves. + await server.services.fs.remove(owner.user.id, { entry: file }); + + let rows = [] as unknown[]; + for (let attempt = 0; attempt < 50; attempt++) { + rows = await server.stores.permission.readLinkedUserUserPerms( + recipient.user.id, + [`fs:${file.uuid}:read`], + ); + if (rows.length === 0) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + expect(rows).toEqual([]); + expect(await canRead(recipient.actor, file.path)).toBe(false); + }); + + describe('keeping recipients in sync', () => { + /** Collect one GUI event's audiences for the life of the callback. */ + const captureAudiences = async ( + event: + | 'outer.gui.item.removed' + | 'outer.gui.item.moved' + | 'outer.gui.item.updated', + uuid: string, + fn: () => Promise, + ) => { + const seen: number[][] = []; + server.clients.event.on(event, (_key, data) => { + const payload = data as { + user_id_list?: number[]; + response?: { uuid?: string }; + }; + if (payload.response?.uuid !== uuid) return; + seen.push(payload.user_id_list ?? []); + }); + await fn(); + for (let i = 0; i < 50 && seen.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return seen; + }; + + it('tells a recipient when a shared file is deleted', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + // Without this the recipient's open window shows a file that is + // gone until their next request happens to fail. + const audiences = await captureAudiences( + 'outer.gui.item.removed', + file.uuid, + async () => { + await server.services.fs.remove(owner.user.id, { + entry: file, + }); + }, + ); + + expect(audiences.flat()).toContain(recipient.user.id); + }); + + it('tells a folder recipient when a file inside it changes', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const { dir, file } = await makeDirWithFile(owner.user); + + // The share is on the folder; the changed file has no row of its + // own, so the fan-out has to look upward to find the audience. + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const audiences = await captureAudiences( + 'outer.gui.item.updated', + file.uuid, + async () => { + await server.clients.event.emitAndWait( + 'fs.write.file', + { node: file }, + {}, + ); + }, + ); + + expect(audiences.flat()).toContain(recipient.user.id); + }); + + it('tells a recipient when a shared file moves', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + + const audiences = await captureAudiences( + 'outer.gui.item.moved', + file.uuid, + async () => { + await server.clients.event.emitAndWait( + 'fs.move.node', + { + node: file, + fromPath: file.path, + toPath: `${file.path}-moved`, + }, + {}, + ); + }, + ); + + expect(audiences.flat()).toContain(recipient.user.id); + // The owner already gets their own event from the FS layer; + // announcing again here would double it up. + expect(audiences.flat()).not.toContain(owner.user.id); + }); + }); + + it('paginates what has been shared with me', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const uids: string[] = []; + for (let i = 0; i < 3; i++) { + const file = await makeFile(owner.user); + uids.push(file.uuid); + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + } + + const seen: string[] = []; + let cursor: string | undefined; + for (let guard = 0; guard < 6; guard++) { + const page = await server.services.share.listSharedWithMe( + recipient.actor, + { limit: 2, cursor }, + ); + seen.push(...page.items.map((i) => i.entryUid)); + cursor = page.cursor; + if (!cursor) break; + } + expect(seen).toEqual(uids); + + const withTotal = await server.services.share.listSharedWithMe( + recipient.actor, + { includeTotal: true }, + ); + expect(withTotal.total).toBe(3); + }); +}); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts new file mode 100644 index 0000000000..0e478fd9d8 --- /dev/null +++ b/src/backend/services/share/ShareService.ts @@ -0,0 +1,1091 @@ +/* + * 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 . + */ + +import { contentType as contentTypeFromMime } from 'mime-types'; +import { posix as pathPosix } from 'node:path'; +import { userRelatedActor, type Actor } from '../../core/actor'; +import { HttpError } from '../../core/http/HttpError.js'; +import type { FSEntry } from '../../stores/fs/FSEntry'; +import type { LayerInstances } from '../../types'; +import type { AclMode } from '../acl/ACLService'; +import type { puterServices } from '../index'; +import { PuterService } from '../types'; +import { + learnShareRoots, + maskEntryPath, + resolveSharePath, +} from '../fs/sharePathMask'; + +// -- Types ------------------------------------------------------------ + +/** A recipient named by whichever identifier the caller had. */ +export interface ShareRecipient { + email?: string; + username?: string; +} + +export interface ShareTarget { + path?: string; + uid?: string; +} + +export interface ShareInput extends ShareTarget { + recipient: ShareRecipient; + mode: AclMode; +} + +/** One live share, resolved for a response. */ +export interface ResolvedShare { + uid: string; + mode: string; + path: string; + /** + * The entry's own name, content type and thumbnail. A share listing has no + * fsentry behind it for the client to stat for them. + */ + 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, and never sent to a client: who to notify, and + * whether this call created reach that didn't exist before. + */ + holderId?: number; + isNew?: boolean; +} + +const SHAREABLE_MODES: ReadonlySet = 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:` does not reach it. + */ +/** The app recorded on a share row, when one issued it. */ +const issuedByApp = (row: { data?: unknown }): string | null => { + const value = (row.data as { issuedByApp?: unknown } | null)?.issuedByApp; + 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}`, +]; + +/** Shares one user may create per UTC day, absent a config override. */ +export const DEFAULT_DAILY_SHARE_LIMIT = 200; + +/** + * How long a recipient stays quiet after one sharer reaches them. Re-sharing an + * item the recipient already has is not new reach and costs no quota, so + * without a window it is an unmetered way to keep interrupting someone. + */ +export const SHARE_NOTIFY_WINDOW_SECONDS = 15 * 60; + +/** + * 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 the entry masked against itself, which always + * resolves; running outside a request, there is no per-request masker to + * consult for a deeper root. + */ +const holderPayload = (entry: FSEntry): Record => ({ + uid: entry.uuid, + uuid: entry.uuid, + name: entry.name, + path: maskedSelfPath(entry, entry.path), + is_dir: Boolean(entry.isDir), + size: entry.size ?? null, + modified: entry.modified, + from_new_service: true, +}); + +/** `///` for a path in the owner's tree. */ +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; +}; + +// -- 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:` grant. + */ +export class ShareService extends PuterService { + declare protected services: LayerInstances; + + /** + * 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) => { + 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.#onEntryRemoved(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) => { + const { node, fromPath } = (data ?? {}) as { + node?: FSEntry; + fromPath?: string; + }; + if (!node?.uuid) return; + return this.#fanOutToHolders(node, 'outer.gui.item.moved', { + ...holderPayload(node), + from_path: fromPath + ? maskedSelfPath(node, fromPath) + : undefined, + }).catch(() => { + // A stale window is better than a failed move. + }); + }); + + this.clients.event.on('fs.write.file', (_key, data) => { + const entry = (data as { node?: FSEntry })?.node; + if (!entry?.uuid) return; + return this.#fanOutToHolders( + entry, + 'outer.gui.item.updated', + holderPayload(entry), + ).catch(() => { + // Same — never fail a write over its notification. + }); + }); + } + + /** + * 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. + */ + async #onEntryRemoved(entry: FSEntry): Promise { + const removed = await this.onEntryDeleted(entry.uuid); + const holders = [ + ...new Set(removed.map((row) => Number(row.holder_user_id))), + ].filter((id) => Number.isFinite(id) && id !== entry.userId); + if (holders.length === 0) return; + + await this.#emitGui( + 'outer.gui.item.removed', + holders, + holderPayload(entry), + ); + } + + async #fanOutToHolders( + entry: FSEntry, + event: 'outer.gui.item.moved' | 'outer.gui.item.updated', + response: Record, + ): Promise { + // Ancestors too: someone given a folder sees what happens inside it, + // and the changed file itself carries no share of its own. + const rows = await this.#sharesReaching(entry); + const holders = [ + ...new Set( + rows.map((row: { holder_user_id: number }) => + Number(row.holder_user_id), + ), + ), + ].filter((id) => Number.isFinite(id) && id !== entry.userId); + if (holders.length === 0) return; + + await this.#emitGui(event, holders, response); + } + + /** + * Active shares on this node or on anything above it. Runs behind every + * write event, so the ancestor paths come off the entry's own path and the + * whole answer is one query. + */ + async #sharesReaching( + entry: FSEntry, + ): Promise> { + const ancestorPaths: string[] = []; + for ( + let cursor = pathPosix.dirname(entry.path); + cursor !== '/' && cursor !== '.'; + cursor = pathPosix.dirname(cursor) + ) { + ancestorPaths.push(cursor); + } + return this.stores.share.listReaching(entry.id, ancestorPaths); + } + + async #emitGui( + event: + | 'outer.gui.item.removed' + | 'outer.gui.item.moved' + | 'outer.gui.item.updated', + userIds: number[], + response: Record, + ): Promise { + 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 { + 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 holder = await this.#resolveRecipient(input.recipient); + + if (holder.id === issuerId) { + throw new HttpError(400, 'cannot share with yourself', { + legacyCode: 'cannot_share_with_self', + }); + } + if (holder.id === entry.userId) { + throw new HttpError(400, 'recipient already owns this item', { + legacyCode: 'cannot_share_with_owner', + }); + } + + // Changing the mode on an existing share isn't new reach, so it + // shouldn't spend budget — only a share to someone who doesn't already + // have one on this node counts. + const existing = await this.stores.share.listByFsentry(entry.id); + const 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: actor.app?.uid ?? null, + }); + 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; + } + } + + /** + * Tell recipients they were given something: one notification per recipient + * per request, and at most one per sharer per window. + * + * Only shares that created new reach count. A mode change is not something + * to interrupt someone for, and re-sharing what they already have spends no + * quota — so the window is what keeps that from becoming a way to spam. + */ + async notifyRecipients(actor: Actor, shares: ResolvedShare[]) { + const counts = new Map(); + for (const share of shares) { + if (!share.isNew || !share.holderId) continue; + counts.set(share.holderId, (counts.get(share.holderId) ?? 0) + 1); + } + if (counts.size === 0) return; + + const issuerId = this.#requireUserId(actor); + const username = actor.user.username; + await Promise.all( + [...counts].map(async ([holderId, count]) => { + if (!(await this.#claimNotifySlot(issuerId, holderId))) return; + await this.services.notification.notify([holderId], { + source: 'sharing', + title: `${username} shared ${count === 1 ? 'an item' : `${count} items`} with you`, + template: 'file-shared-with-you', + fields: { username, count }, + }); + }), + ); + } + + /** False when this pair was already notified inside the window. */ + async #claimNotifySlot( + issuerId: number, + holderId: number, + ): Promise { + try { + const claimed = await this.clients.redis.set( + `share:notify:${issuerId}:${holderId}`, + '1', + 'EX', + SHARE_NOTIFY_WINDOW_SECONDS, + 'NX', + ); + return claimed === 'OK'; + } catch { + // Notifying twice beats going silent when the cache is down. + return true; + } + } + + /** Whether `issuerId` already grants `holderId` anything on this node. */ + async #hasGrantFrom( + entry: FSEntry, + holderId: number, + issuerId: number, + ): Promise { + const rows = await this.stores.permission.readLinkedUserUserPerms( + holderId, + entryPermissions(entry.uuid), + ); + return rows.some((row) => Number(row.issuer_user_id) === issuerId); + } + + /** + * Withdraw a recipient's access. An owner may clear any issuer's share of + * their node; anyone else may only clear the ones they issued. + */ + async unshare( + actor: Actor, + input: ShareTarget & { recipient: ShareRecipient }, + ): Promise<{ revoked: number }> { + const issuerId = this.#requireUserId(actor); + const [entry, holder] = await Promise.all([ + this.#resolveEntry(input, actor), + this.#resolveRecipient(input.recipient), + ]); + + // Dropping your own access needs no authority over the node — only + // enough visibility that the call can't be used to probe for one. + const isLeaving = holder.id === issuerId; + if (isLeaving) { + await this.#assertCanSee(actor, entry); + } else { + await this.#assertCanManage(actor, entry); + } + + if (holder.id === entry.userId) { + throw new HttpError(400, 'cannot revoke the owner of an item', { + legacyCode: 'cannot_revoke_owner', + }); + } + + // An owner may clear any issuer's share of their node; anyone else may + // clear the ones they issued, or their own access. + const isOwner = entry.userId === issuerId; + const rows = (await this.stores.share.listByFsentry(entry.id)).filter( + (row: { holder_user_id: number; issuer_user_id: number }) => + row.holder_user_id === holder.id && + (isOwner || isLeaving || row.issuer_user_id === issuerId), + ); + + // Fall back to the issuer's own grant when no index row exists — the + // grant may predate the index, and a revoke must still work. + const issuers = + rows.length > 0 + ? [ + ...new Set( + rows.map( + (row: { issuer_user_id: number }) => + row.issuer_user_id, + ), + ), + ] + : [issuerId]; + + // 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 as number, + ); + if (didRevoke) revoked++; + if (authorized) { + await this.stores.share.deleteActive({ + holderUserId: holder.id, + fsentryId: entry.id, + issuerUserId: issuer as number, + }); + } + } + 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 = new Set(), + ): Promise { + if (seen.has(issuerId)) return 0; + seen.add(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 = ( + 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( + entryUid: string, + ): Promise> { + // Two prefixes, because `manage:fs:` does not sit under + // `fs:` — 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( + [`fs:${entryUid}`, `manage:fs:${entryUid}`], + ); + + // 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; + } + + // -- 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); + + // A session sees everything shared with it. An app sees only the part + // of that its user handed to the app — this listing is otherwise the + // one share surface with no per-entry check behind it. + const reachable = await 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 (!reachable.has(entry.uuid)) continue; + const issuer = issuers.get(Number(row.issuer_user_id)); + const owner = issuers.get(Number(entry.userId)); + items.push({ + uid: row.uid, + mode: row.mode, + path: maskEntryPath(entry), + name: entry.name, + type: entry.isDir + ? 'folder' + : contentTypeFromMime(entry.name) || null, + thumbnail: entry.thumbnail ?? null, + entryUid: entry.uuid, + isDir: Boolean(entry.isDir), + owner: { username: owner?.username ?? null }, + issuer: { username: issuer?.username ?? null }, + holder: { username: actor.user.username ?? null }, + createdAt: row.created_at, + modified: entry.modified, + size: entry.size, + }); + } + + return { + items, + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(opts.includeTotal + ? { total: await this.stores.share.countByHolder(holderId) } + : {}), + }; + } + + /** + * Who can reach one node. Includes shares a `manage` delegate issued, which + * the permission tables alone can't show the owner. + */ + async listSharesOf( + actor: Actor, + target: ShareTarget, + ): Promise { + 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 inherited: Array<{ row: Record; via: string }> = + (await this.stores.share.listByFsentries([...viaById.keys()])).map( + (row: { fsentry_id: number }) => ({ + row, + via: viaById.get(Number(row.fsentry_id)) as string, + }), + ); + + const rows = await this.stores.share.listByFsentry(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), + ], + ); + const users = await this.stores.user.getByIds(userIds); + const maskedPath = maskEntryPath(entry); + + const inheritedShares: ResolvedShare[] = inherited.map( + ({ row, via }) => ({ + uid: String(row.uid), + mode: String(row.mode), + path: maskedPath, + entryUid: entry.uuid, + isDir: Boolean(entry.isDir), + issuer: { + username: + users.get(Number(row.issuer_user_id))?.username ?? null, + }, + holder: { + username: + users.get(Number(row.holder_user_id))?.username ?? null, + }, + createdAt: row.created_at, + issuedByApp: issuedByApp(row), + inheritedFrom: via, + modified: entry.modified, + size: entry.size, + }), + ); + + const own: ResolvedShare[] = rows.map( + (row: { + uid: string; + mode: string; + issuer_user_id: number; + holder_user_id: number; + created_at: unknown; + data?: unknown; + }): ResolvedShare => ({ + uid: row.uid, + mode: row.mode, + path: maskedPath, + entryUid: entry.uuid, + isDir: Boolean(entry.isDir), + issuer: { + username: + users.get(Number(row.issuer_user_id))?.username ?? null, + }, + holder: { + username: + users.get(Number(row.holder_user_id))?.username ?? null, + }, + createdAt: row.created_at, + issuedByApp: issuedByApp(row), + inheritedFrom: null, + modified: entry.modified, + size: entry.size, + }), + ); + return inheritedShares.concat(own); + } + + // -- Internals ---------------------------------------------------- + + #resolve( + row: { uid: string; mode: string; created_at?: unknown }, + entry: FSEntry, + issuer: Actor, + holder: { username: string | null }, + ): ResolvedShare { + return { + uid: row.uid, + mode: row.mode, + path: maskEntryPath(entry), + 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 { + 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; + } + + async #resolveRecipient(recipient: ShareRecipient) { + const email = recipient?.email?.trim(); + const username = recipient?.username?.trim(); + const user = email + ? await this.stores.user.getByEmail(email) + : username + ? await this.stores.user.getByUsername(username) + : null; + // 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 (!user?.username || unconfirmedEmailMatch) { + throw new HttpError(404, 'Recipient does not exist', { + legacyCode: 'user_does_not_exist', + }); + } + return user; + } + + /** + * Gate every share operation on the same question the permission layer + * already answers. Reported as the ACL's own safe error so a caller who + * can't even see the node learns nothing from the difference. + */ + async #assertCanManage( + actor: Actor, + entry: FSEntry, + mode: AclMode = 'see', + ): Promise { + // 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. + const allowed = await this.services.permission.canManagePermission( + userRelatedActor(actor), + `fs:${entry.uuid}:read`, + ); + // 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; + + 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> { + 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 { + 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> { + const limit = + this.config.share_daily_limit ?? DEFAULT_DAILY_SHARE_LIMIT; + const noop = async () => {}; + if (limit <= 0) return noop; + + const release = async (): Promise => { + 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 { + const descriptor = this.#descriptorFor(entry); + if (await this.services.acl.check(actor, descriptor, 'see')) return; + const safe = await this.services.acl.getSafeAclError( + actor, + descriptor, + 'see', + ); + throw new HttpError(safe.status, safe.message, { + legacyCode: safe.fields.code, + }); + } + + #descriptorFor(entry: FSEntry) { + const fsService = this.services.fs; + let cache: Promise< + ReadonlyArray<{ uid: string; path: string }> + > | null = null; + return { + path: entry.path, + resolveAncestors: () => { + if (!cache) cache = fsService.getAncestorChain(entry.path); + return cache; + }, + }; + } + + #isTrashed(entry: FSEntry): boolean { + return /^\/[^/]+\/Trash(\/|$)/u.test(entry.path); + } + + /** + * Clear whichever modes the recipient holds on this node. + * + * Skips any the actor can't manage rather than aborting: stripping a + * `manage` grant needs 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 { + try { + await this.#revokeFor(actor, entry, username, issuerUserId); + } catch { + // Already failing the request; don't mask the original error. + } + } +} diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index d56a625ae7..57d9875380 100644 --- a/src/backend/stores/fs/FSEntryStore.ts +++ b/src/backend/stores/fs/FSEntryStore.ts @@ -697,7 +697,7 @@ export class FSEntryStore extends PuterStore { ); insertRows.push( expectedUuid, - userId, + parentEntry ? parentEntry.userId : userId, parentEntry ? parentEntry.id : null, parentEntry ? parentEntry.uuid : null, pathPosix.basename(dirPath), @@ -848,7 +848,7 @@ export class FSEntryStore extends PuterStore { ) VALUES (?, ?, ?, ?, ?, ?, ${trueLiteral}, ?, ?, ?, ${falseLiteral}, 0)${this.clients.db.insertIgnoreSuffix()}`, [ uuidv4(), - userId, + parentEntry ? parentEntry.userId : userId, parentEntry ? parentEntry.id : null, parentEntry ? parentEntry.uuid : null, dirName, @@ -1875,7 +1875,7 @@ export class FSEntryStore extends PuterStore { entry.input.uuid, entry.bucket, entry.bucketRegion, - userId, + parentEntry.userId, parentEntry.id, parentEntry.uuid, entry.input.associatedAppId ?? null, @@ -1948,9 +1948,11 @@ export class FSEntryStore extends PuterStore { const placeholders = insertUuidChunk .map(() => '?') .join(', '); + // By uuid alone — the rows just written belong to the + // parent's owner, not necessarily the acting user. const rows = (await this.clients.db.tryHardRead( - `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE user_id = ? AND uuid IN (${placeholders})`, - [userId, ...insertUuidChunk], + `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE uuid IN (${placeholders})`, + insertUuidChunk, )) as unknown as FSEntryRow[]; const insertedEntries = rows.map((row) => @@ -2276,9 +2278,11 @@ export class FSEntryStore extends PuterStore { * * Returns the inserted entry with a refreshed row read. Throws 409 on a * unique-key collision (caller should pre-check and dedupe). + * + * The row belongs to whoever owns `parent`, not to whoever created it, so a + * shared subtree keeps one owner throughout. */ async createNonFileEntry(input: { - userId: number; parent: FSEntry; name: string; kind: 'directory' | 'shortcut' | 'symlink' | 'empty-file'; @@ -2331,7 +2335,7 @@ export class FSEntryStore extends PuterStore { ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, - input.userId, + input.parent.userId, input.parent.id, input.parent.uuid, input.name, @@ -2776,6 +2780,7 @@ export class FSEntryStore extends PuterStore { patch: { name?: string; path?: string; + userId?: number; parentId?: number | null; parentUid?: string | null; thumbnail?: string | null; @@ -2800,6 +2805,7 @@ export class FSEntryStore extends PuterStore { if (patch.name !== undefined) push('name', patch.name); if (patch.path !== undefined) push('path', patch.path); + if (patch.userId !== undefined) push('user_id', patch.userId); if (patch.parentId !== undefined) push('parent_id', patch.parentId); if (patch.parentUid !== undefined) push('parent_uid', patch.parentUid); if (patch.thumbnail !== undefined) push('thumbnail', patch.thumbnail); @@ -2947,11 +2953,13 @@ export class FSEntryStore extends PuterStore { // Rewrites path column for every descendant of `oldPrefix` to use `newPrefix`. // Used by move/rename when a directory is relocated. Cache for affected - // entries is invalidated coarsely afterwards by the caller. + // entries is invalidated coarsely afterwards by the caller. Pass + // `newUserId` when the subtree also changes hands. async updatePathPrefixForUser( userId: number, oldPrefix: string, newPrefix: string, + newUserId?: number, ): Promise { const normalizedOld = this.#normalizePath(oldPrefix); const normalizedNew = this.#normalizePath(newPrefix); @@ -2973,12 +2981,21 @@ export class FSEntryStore extends PuterStore { postgres: '? || SUBSTR(path, ?)', otherwise: 'CONCAT(?, SUBSTR(path, ?))', }); + const reowning = newUserId !== undefined && newUserId !== userId; const result = await this.clients.db.write( `UPDATE fsentries SET path = ${rewrittenPath}, + ${reowning ? 'user_id = ?,' : ''} modified = ? WHERE user_id = ? AND path LIKE ? ESCAPE '!'`, - [normalizedNew, oldPrefixLen + 1, now, userId, likePattern], + [ + normalizedNew, + oldPrefixLen + 1, + ...(reowning ? [newUserId] : []), + now, + userId, + likePattern, + ], ); const affected = this.#affectedRows(result); return affected; diff --git a/src/backend/stores/permission/PermissionStore.test.ts b/src/backend/stores/permission/PermissionStore.test.ts index a9fe1d1588..72fa59222a 100644 --- a/src/backend/stores/permission/PermissionStore.test.ts +++ b/src/backend/stores/permission/PermissionStore.test.ts @@ -3,18 +3,19 @@ * * 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. + * 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. + * 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 . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import { readFileSync } from 'fs'; @@ -651,6 +652,136 @@ describe('PermissionStore', () => { expect(await store.getScanCache(nextKey)).toBeNull(); }); + it('announces a bump so peer regions can bump their own counter', async () => { + const actorUid = `actor-${uuidv4()}`; + const seen: unknown[] = []; + server.clients.event.on( + 'outer.permission.generationBumped', + (_key, data) => { + seen.push(data); + }, + ); + + await store.bumpCacheGeneration(actorUid); + expect(seen).toContainEqual({ actorUids: [actorUid] }); + }); + + it('applies a remote bump without re-announcing it', async () => { + const actorUid = `actor-${uuidv4()}`; + const before = await store.getCacheGeneration(actorUid); + let announced = 0; + server.clients.event.on( + 'outer.permission.generationBumped', + (_key, _data, meta) => { + if (!(meta as { from_outside?: boolean })?.from_outside) { + announced++; + } + }, + ); + + // What BroadcastService does with an inbound webhook event. + await server.clients.event.emitAndWait( + 'outer.permission.generationBumped', + { actorUids: [actorUid] }, + { from_outside: true }, + ); + + expect(await store.getCacheGeneration(actorUid)).toBeGreaterThan( + before, + ); + // Re-announcing would ping-pong between regions forever. + expect(announced).toBe(0); + }); + + it('ignores a locally-emitted bump event', async () => { + const actorUid = `actor-${uuidv4()}`; + await server.clients.event.emitAndWait( + 'outer.permission.generationBumped', + { actorUids: [actorUid] }, + {}, + ); + expect(await store.getCacheGeneration(actorUid)).toBe(0); + }); + + it('announces a flat delete so peer regions drop their own copy', async () => { + const holder = await makeUser(); + await store.setFlatUserPerm(holder.id, 'fs:u:read', { + permission: 'fs:u:read', + deleted: false, + } as never); + + const seen: unknown[] = []; + server.clients.event.on( + 'outer.permission.flatInvalidated', + (_key, data) => { + seen.push(data); + }, + ); + + await store.delFlatUserPerm(holder.id, 'fs:u:read'); + expect(seen).toContainEqual({ + entries: [{ holderUserId: holder.id, permission: 'fs:u:read' }], + }); + }); + + it('applies a remote flat delete without re-announcing it', async () => { + const holder = await makeUser(); + await store.setFlatUserPerm(holder.id, 'fs:u:read', { + permission: 'fs:u:read', + deleted: false, + } as never); + expect( + await store.getFlatUserPerms(holder.id, ['fs:u:read']), + ).toHaveLength(1); + + let announced = 0; + server.clients.event.on( + 'outer.permission.flatInvalidated', + (_key, _data, meta) => { + if (!(meta as { from_outside?: boolean })?.from_outside) { + announced++; + } + }, + ); + + await server.clients.event.emitAndWait( + 'outer.permission.flatInvalidated', + { + entries: [ + { holderUserId: holder.id, permission: 'fs:u:read' }, + ], + }, + { from_outside: true }, + ); + + expect( + await store.getFlatUserPerms(holder.id, ['fs:u:read']), + ).toEqual([]); + expect(announced).toBe(0); + }); + + it('ignores a locally-emitted flat invalidation', async () => { + const holder = await makeUser(); + await store.setFlatUserPerm(holder.id, 'fs:u:read', { + permission: 'fs:u:read', + deleted: false, + } as never); + + await server.clients.event.emitAndWait( + 'outer.permission.flatInvalidated', + { + entries: [ + { holderUserId: holder.id, permission: 'fs:u:read' }, + ], + }, + {}, + ); + + expect( + await store.getFlatUserPerms(holder.id, ['fs:u:read']), + ).toHaveLength(1); + }); + it('drops a scan cache entry on explicit invalidation', async () => { const key = store.buildScanCacheKey(`actor-${uuidv4()}`, ['p'], 0); await store.setScanCache(key, { allowed: false }); @@ -705,7 +836,7 @@ describe('PermissionStore', () => { // -- user → user ------------------------------------------------------ describe('user-to-user permissions', () => { - it('lists issuers for a holder and clears them on revoke', async () => { + it('reads a holder grant back and clears it on revoke', async () => { const issuer = await makeUser(); const holder = await makeUser(); await store.upsertUserUserPerm( @@ -715,27 +846,189 @@ describe('PermissionStore', () => { {}, ); - expect( - await store.listUserPermissionIssuerIds(holder.id), - ).toContain(issuer.id); - const rows = await store.readLinkedUserUserPerms(holder.id, [ 'fs:u:read', ]); expect(rows).toHaveLength(1); expect(rows[0].issuer_user_id).toBe(issuer.id); - await store.deleteUserUserPermByHolder(holder.id, 'fs:u:read'); + await store.deleteUserUserPermByHolder( + holder.id, + 'fs:u:read', + issuer.id, + ); expect( await store.readLinkedUserUserPerms(holder.id, ['fs:u:read']), ).toEqual([]); }); + it('broadcasts a revoked row-cache key so peer regions drop it too', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + await store.upsertUserUserPerm( + holder.id, + issuer.id, + 'fs:u:read', + {}, + ); + + const seen: unknown[] = []; + server.clients.event.on('outer.cacheUpdate', (_key, data) => { + seen.push(data); + }); + + await store.deleteUserUserPermByHolder( + holder.id, + 'fs:u:read', + issuer.id, + ); + + // Without the broadcast, a peer region's warm `perms:u2u:holder:*` + // cache keeps serving the revoked row for its full TTL — and every + // scan there re-warms the flat view from it. + expect(seen).toContainEqual({ + cacheKey: [`perms:u2u:holder:${holder.id}`], + }); + }); + it('returns nothing for an empty permission list', async () => { const holder = await makeUser(); expect(await store.readLinkedUserUserPerms(holder.id, [])).toEqual( [], ); }); + + it('deletes every grant at or beneath a permission prefix', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const other = await makeUser(); + const uid = uuidv4(); + + for (const [h, perm] of [ + [holder, `fs:${uid}:read`], + [other, `fs:${uid}:write`], + [holder, `fs:${uid}`], + ] as const) { + await store.upsertUserUserPerm(h.id, issuer.id, perm, {}); + await store.setFlatUserPerm(h.id, perm, { + permission: perm, + deleted: false, + } as never); + } + // A different entry must survive. + const keeper = `fs:${uuidv4()}:read`; + await store.upsertUserUserPerm(holder.id, issuer.id, keeper, {}); + + const removed = await store.deleteUserUserPermsByPermissionPrefix( + `fs:${uid}`, + ); + + expect(removed).toHaveLength(3); + expect( + await store.readLinkedUserUserPerms(holder.id, [ + `fs:${uid}:read`, + `fs:${uid}`, + ]), + ).toEqual([]); + expect( + await store.getFlatUserPerms(holder.id, [`fs:${uid}:read`]), + ).toEqual([]); + expect( + await store.readLinkedUserUserPerms(holder.id, [keeper]), + ).toHaveLength(1); + }); + + it('does not let a wildcard in the prefix widen the match', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const victim = `fs:${uuidv4()}:read`; + await store.upsertUserUserPerm(holder.id, issuer.id, victim, {}); + + // `_` and `%` are LIKE wildcards; unescaped they would match this. + expect( + await store.deleteUserUserPermsByPermissionPrefix('fs:%'), + ).toEqual([]); + expect( + await store.deleteUserUserPermsByPermissionPrefix('fs:_'), + ).toEqual([]); + expect( + await store.readLinkedUserUserPerms(holder.id, [victim]), + ).toHaveLength(1); + }); + + it('returns an empty list when the prefix matches nothing', async () => { + expect( + await store.deleteUserUserPermsByPermissionPrefix( + `fs:${uuidv4()}`, + ), + ).toEqual([]); + }); + + it('revokes only the named issuer grant, not another issuer identical one', async () => { + const issuerA = await makeUser(); + const issuerB = await makeUser(); + const holder = await makeUser(); + await store.upsertUserUserPerm( + holder.id, + issuerA.id, + 'fs:shared:read', + {}, + ); + await store.upsertUserUserPerm( + holder.id, + issuerB.id, + 'fs:shared:read', + {}, + ); + + expect( + await store.deleteUserUserPermByHolder( + holder.id, + 'fs:shared:read', + issuerA.id, + ), + ).toBe(true); + + // Two people can grant the same access independently; one of them + // withdrawing must not take the other's grant with it. + const rows = await store.readLinkedUserUserPerms(holder.id, [ + 'fs:shared:read', + ]); + expect(rows).toHaveLength(1); + expect(rows[0].issuer_user_id).toBe(issuerB.id); + }); + + it('reports whether the delete matched a row', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + await store.upsertUserUserPerm( + holder.id, + issuer.id, + 'fs:u:read', + {}, + ); + + expect( + await store.deleteUserUserPermByHolder( + holder.id, + 'fs:u:read', + issuer.id, + ), + ).toBe(true); + expect( + await store.deleteUserUserPermByHolder( + holder.id, + 'fs:u:read', + issuer.id, + ), + ).toBe(false); + expect( + await store.deleteUserUserPermByHolder( + holder.id, + 'fs:never-granted:read', + issuer.id, + ), + ).toBe(false); + }); }); }); diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 2f7326204e..0ed4e883de 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -85,6 +85,12 @@ export interface AuditEntry { [k: string]: unknown; } +/** One entry in the flat KV view, as addressed by a delete. */ +export interface FlatPermRef { + holderUserId: number; + permission: string; +} + /** * PermissionStore owns the _persistence_ side of permissions: * @@ -98,6 +104,68 @@ export interface AuditEntry { export class PermissionStore extends PuterStore { declare protected stores: LayerInstances; + override onServerStart(): void { + this.#subscribeRemoteGenerationBumps(); + this.#subscribeRemoteFlatInvalidations(); + } + + /** + * Apply a peer region's flat-permission deletes. Independent of whether the + * KV table replicates: a redundant delete is a no-op, a needed one is the + * only thing that makes the revoke real there. + */ + #subscribeRemoteFlatInvalidations(): void { + this.clients.event.on( + 'outer.permission.flatInvalidated', + (_key, data, meta) => { + if (!(meta as { from_outside?: boolean })?.from_outside) return; + const raw = (data as { entries?: unknown })?.entries; + if (!Array.isArray(raw)) return; + const entries = raw.filter( + (entry): entry is FlatPermRef => + typeof (entry as FlatPermRef)?.holderUserId === + 'number' && + typeof (entry as FlatPermRef)?.permission === + 'string' && + (entry as FlatPermRef).permission !== '', + ); + if (entries.length === 0) return; + // Guarded: a transient KV error applying a peer's delete must + // not become an unhandled rejection. The entry stays until the + // next invalidation or its TTL — same as a lost event. + this.#applyFlatUserPermDeletes(entries).catch((err) => { + console.warn( + '[PermissionStore] failed to apply remote flat-perm deletes:', + err, + ); + }); + }, + ); + } + + /** + * Apply a peer region's cache-generation bumps. Our own emit reaches local + * listeners too, and that half already ran before it went out. + */ + #subscribeRemoteGenerationBumps(): void { + this.clients.event.on( + 'outer.permission.generationBumped', + (_key, data, meta) => { + if (!(meta as { from_outside?: boolean })?.from_outside) return; + const raw = (data as { actorUids?: unknown })?.actorUids; + if (!Array.isArray(raw)) return; + const actorUids = raw.filter( + (uid): uid is string => + typeof uid === 'string' && uid !== '', + ); + if (actorUids.length === 0) return; + void Promise.all( + actorUids.map((uid) => this.#applyCacheGenerationBump(uid)), + ); + }, + ); + } + // -- Flat view (KV under system namespace) ------------------------ /** @@ -155,12 +223,41 @@ export class PermissionStore extends PuterStore { holderUserId: number, permission: string, ): Promise { - const key = PermissionUtil.join( - PERM_KEY_PREFIX, - String(holderUserId), - permission, + await this.delFlatUserPerms([{ holderUserId, permission }]); + } + + /** + * Delete many flat entries and tell peer regions once. Retiring a shared + * directory can touch hundreds of grants; one event per grant would put + * that whole fan-out on the cross-region path. + */ + async delFlatUserPerms(entries: FlatPermRef[]): Promise { + if (entries.length === 0) return; + await this.#applyFlatUserPermDeletes(entries); + try { + this.clients.event.emit( + 'outer.permission.flatInvalidated', + { entries }, + {}, + ); + } catch { + // Peer regions keep the entries until their KV replicates. + } + } + + /** Local half of a flat delete. Never emits, so a remote one can't loop. */ + async #applyFlatUserPermDeletes(entries: FlatPermRef[]): Promise { + await Promise.all( + entries.map(({ holderUserId, permission }) => + this.stores.kv.del({ + key: PermissionUtil.join( + PERM_KEY_PREFIX, + String(holderUserId), + permission, + ), + }), + ), ); - await this.stores.kv.del({ key }); } // -- SQL: user-to-user permissions ------------------------------- @@ -175,6 +272,37 @@ export class PermissionStore extends PuterStore { return all.filter((row) => wanted.has(row.permission)); } + /** + * Read-after-write variant of {@link readLinkedUserUserPerms}: skips the row + * cache and queries the primary, so a check that immediately follows a + * write on this holder cannot be misled by replica lag or by a stale cached + * row set. Re-warms the cache with what the primary returned — the plain + * read path would otherwise re-cache the replica's stale view. + */ + async readLinkedUserUserPermsFromPrimary( + holderUserId: number, + permissions: string[], + ): Promise { + if (permissions.length === 0) return []; + const rows = await this.clients.db.pread( + 'SELECT * FROM `user_to_user_permissions` WHERE `holder_user_id` = ?', + [holderUserId], + ); + const decoded = rows.map((row) => + this.#decodeExtra(row), + ); + this.clients.redis + .set( + this.#u2uCacheKey(holderUserId), + JSON.stringify(decoded), + 'EX', + U2U_CACHE_TTL_SECONDS, + ) + .catch(() => {}); + const wanted = new Set(permissions); + return decoded.filter((row) => wanted.has(row.permission)); + } + async upsertUserUserPerm( holderUserId: number, issuerUserId: number, @@ -198,20 +326,107 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2uCacheKey(holderUserId)], + broadcast: true, }); } + /** + * Remove one issuer's grant. Scoped to the issuer because grants are keyed + * on (holder, issuer, permission) — two people can grant the same access + * independently, and one withdrawing must not take the other's with it. + * + * Returns whether a row was actually deleted. + */ async deleteUserUserPermByHolder( holderUserId: number, permission: string, - ): Promise { - await this.clients.db.write( - 'DELETE FROM `user_to_user_permissions` WHERE `holder_user_id` = ? AND `permission` = ?', - [holderUserId, permission], + issuerUserId: number, + ): Promise { + const result = await this.clients.db.write( + 'DELETE FROM `user_to_user_permissions` WHERE `holder_user_id` = ? ' + + 'AND `permission` = ? AND `issuer_user_id` = ?', + [holderUserId, permission, issuerUserId], ); + if (!result.anyRowsAffected) return false; await this.publishCacheKeys({ keys: [this.#u2uCacheKey(holderUserId)], + broadcast: true, }); + return true; + } + + /** + * Delete every user-to-user grant at or beneath `permission`, clearing the + * flat KV view too, and return the rows removed so the caller can audit + * them and bust caches. + * + * The subject lives in the permission text rather than a column, so no + * foreign key can cascade it — this is how a deleted fsentry's grants get + * withdrawn. `permission` has no index, so this is a table scan: fine on + * deletion, never on a hot path. + */ + async deleteUserUserPermsByPermissionPrefix(permission: string): Promise< + Array<{ + holder_user_id: number; + issuer_user_id: number; + permission: string; + }> + > { + return this.deleteUserUserPermsByPermissionPrefixes([permission]); + } + + /** As above, for several prefixes in one scan. */ + async deleteUserUserPermsByPermissionPrefixes( + permissions: string[], + ): Promise< + Array<{ + holder_user_id: number; + issuer_user_id: number; + permission: string; + }> + > { + if (permissions.length === 0) return []; + // See deleteAppGrantsByPermissionPrefix for why `!` is the escape. + const where = permissions + .map(() => "(`permission` = ? OR `permission` LIKE ? ESCAPE '!')") + .join(' OR '); + const params = permissions.flatMap((permission) => [ + permission, + `${permission.replace(/([!%_])/g, '!$1')}:%`, + ]); + + const rows = (await this.clients.db.read( + 'SELECT `holder_user_id`, `issuer_user_id`, `permission` FROM `user_to_user_permissions` ' + + `WHERE ${where}`, + params, + )) as Array<{ + holder_user_id: number; + issuer_user_id: number; + permission: string; + }>; + if (rows.length === 0) return []; + + await this.clients.db.write( + `DELETE FROM \`user_to_user_permissions\` WHERE ${where}`, + params, + ); + + // One batched invalidation, so retiring a busy directory doesn't put a + // row-sized fan-out on the cross-region path. + await this.delFlatUserPerms( + rows.map((row) => ({ + holderUserId: row.holder_user_id, + permission: row.permission, + })), + ); + + const keys = [ + ...new Set(rows.map((r) => this.#u2uCacheKey(r.holder_user_id))), + ]; + if (keys.length > 0) + await this.publishCacheKeys({ keys, broadcast: true }); + + return rows; } async auditUserUserPerm( @@ -237,14 +452,6 @@ export class PermissionStore extends PuterStore { ); } - async listUserPermissionIssuerIds(holderUserId: number): Promise { - const rows = await this.clients.db.read( - 'SELECT DISTINCT issuer_user_id FROM `user_to_user_permissions` WHERE `holder_user_id` = ?', - [holderUserId], - ); - return rows.map((r) => Number(r.issuer_user_id)); - } - // -- SQL: user-to-app permissions -------------------------------- async readUserAppPerms( @@ -290,6 +497,7 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2aCacheKey(userId, appId)], + broadcast: true, }); } @@ -304,6 +512,7 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2aCacheKey(userId, appId)], + broadcast: true, }); } @@ -314,6 +523,7 @@ export class PermissionStore extends PuterStore { ); await this.publishCacheKeys({ keys: [this.#u2aCacheKey(userId, appId)], + broadcast: true, }); } @@ -386,7 +596,8 @@ export class PermissionStore extends PuterStore { .map((r) => this.#u2aCacheKey(r.user_id, r.app_id)), ), ]; - if (keys.length > 0) await this.publishCacheKeys({ keys }); + if (keys.length > 0) + await this.publishCacheKeys({ keys, broadcast: true }); return removed; } @@ -591,6 +802,7 @@ export class PermissionStore extends PuterStore { async invalidateAccessTokenPerms(tokenUid: string): Promise { await this.publishCacheKeys({ keys: [this.#tokenCacheKey(tokenUid)], + broadcast: true, }); } @@ -689,6 +901,29 @@ export class PermissionStore extends PuterStore { } async bumpCacheGeneration(actorUid: string): Promise { + await this.bumpCacheGenerations([actorUid]); + } + + /** Bump several actors and tell peer regions once. */ + async bumpCacheGenerations(actorUids: string[]): Promise { + const unique = [...new Set(actorUids.filter(Boolean))]; + if (unique.length === 0) return; + await Promise.all( + unique.map((uid) => this.#applyCacheGenerationBump(uid)), + ); + try { + this.clients.event.emit( + 'outer.permission.generationBumped', + { actorUids: unique }, + {}, + ); + } catch { + // Peer regions fall back to their scan-cache TTL. + } + } + + /** Local half of a bump. Never emits, so a remote bump can't ping-pong. */ + async #applyCacheGenerationBump(actorUid: string): Promise { const key = this.#cacheGenerationKey(actorUid); try { const next = await this.clients.redis.incr(key); diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 182505e01c..cccfb73fef 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -18,17 +18,26 @@ */ import { v4 as uuidv4 } from 'uuid'; +import { encodeCursor, decodeCursor } from '../../util/pagination'; import { PuterStore } from '../types'; +/** Default page size for `listByHolder`. */ +const DEFAULT_HOLDER_PAGE_SIZE = 50; +const MAX_HOLDER_PAGE_SIZE = 200; + /** * CRUD over the `share` table. * - * Columns: id, uid (unique), issuer_user_id, recipient_email, data (JSON), - * created_at. + * Columns: id, uid (unique), issuer_user_id, recipient_email, holder_user_id, + * fsentry_id, mode, data (JSON), created_at, applied_at. + * + * The table carries two related things. A row with a `holder_user_id` is an + * **active share** — the index that makes shares listable and ties them to an + * fsentry so they die with the file. A row without one is a **pending invite** + * to an email that has no account yet; claiming it fills in the holder rather + * than deleting the row, so the share stays queryable afterwards. * - * Shares are pending permission grants sent to an email address. Once the - * recipient applies the share, the permissions are granted and the row is - * deleted. + * Permissions remain the source of truth for access. This is the index. */ export class ShareStore extends PuterStore { // -- Reads -------------------------------------------------------- @@ -57,6 +66,125 @@ export class ShareStore extends PuterStore { return rows.map((r) => this.#normalizeRow(r)); } + /** + * Active shares held by a user, keyset-paginated. `id` is the tiebreaker, + * so a row added mid-iteration can't shift earlier pages. + * + * Returns rows only; the caller hydrates fsentries (batched) and drops any + * whose entry it can't resolve. Paths are deliberately not stored — a move + * or rename would strand them. + */ + async listByHolder(holderUserId, { limit, cursor } = {}) { + const size = Math.min( + Math.max(1, Math.floor(Number(limit) || DEFAULT_HOLDER_PAGE_SIZE)), + MAX_HOLDER_PAGE_SIZE, + ); + const decoded = decodeCursor(cursor, 'share cursor'); + const afterId = Number(decoded?.id ?? 0) || 0; + + // One extra row tells us whether another page exists. + const rows = await this.clients.db.read( + 'SELECT * FROM `share` WHERE `holder_user_id` = ? AND `id` > ? ' + + 'ORDER BY `id` LIMIT ?', + [holderUserId, afterId, size + 1], + ); + + const hasMore = rows.length > size; + const items = (hasMore ? rows.slice(0, size) : rows).map((r) => + this.#normalizeRow(r), + ); + const last = items[items.length - 1]; + return { + items, + cursor: hasMore && last ? encodeCursor({ id: last.id }) : undefined, + }; + } + + /** Everyone with an active share on one node, whoever issued it. */ + async listByFsentry(fsentryId) { + return this.listByFsentries([fsentryId]); + } + + /** As above, across several nodes in one query. */ + async listByFsentries(fsentryIds) { + if (fsentryIds.length === 0) return []; + const placeholders = fsentryIds.map(() => '?').join(', '); + const rows = await this.clients.db.read( + `SELECT * FROM \`share\` WHERE \`fsentry_id\` IN (${placeholders}) ` + + 'AND `holder_user_id` IS NOT NULL ORDER BY `id`', + fsentryIds, + ); + return rows.map((r) => this.#normalizeRow(r)); + } + + /** + * Every active share the holder has on any of `fsentryIds`. Used to find + * which shared root an entry was reached through, in one round trip. + */ + async listByHolderAndFsentries(holderUserId, fsentryIds) { + if (fsentryIds.length === 0) return []; + const placeholders = fsentryIds.map(() => '?').join(', '); + const rows = await this.clients.db.read( + `SELECT * FROM \`share\` WHERE \`holder_user_id\` = ? AND ` + + `\`fsentry_id\` IN (${placeholders}) ORDER BY \`id\``, + [holderUserId, ...fsentryIds], + ); + return rows.map((r) => this.#normalizeRow(r)); + } + + /** + * Active shares on a directory and everything beneath it. Walks by parent + * linkage, not path prefix — `fsentries.path` is lazily backfilled and NULL + * on old rows, so a LIKE would skip those descendants' shares. + * + * @param {number} fsentryId + */ + 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`' + + ') ' + + 'SELECT `share`.* FROM `share` ' + + 'JOIN `subtree` ON `share`.`fsentry_id` = `subtree`.`id` ' + + 'WHERE `share`.`holder_user_id` IS NOT NULL ' + + 'ORDER BY `share`.`id`', + [fsentryId], + ); + return rows.map((r) => this.#normalizeRow(r)); + } + + /** + * Active shares on one node or any of its ancestors, named by path. One + * query, because this sits behind every file-write event — the caller + * derives the ancestor paths from the entry's own path for free. + * + * @param {number} fsentryId + * @param {string[]} ancestorPaths + */ + async listReaching(fsentryId, ancestorPaths) { + const placeholders = ancestorPaths.map(() => '?').join(', '); + const rows = await this.clients.db.read( + 'SELECT `share`.* FROM `share` ' + + 'JOIN `fsentries` `f` ON `share`.`fsentry_id` = `f`.`id` ' + + 'WHERE `share`.`holder_user_id` IS NOT NULL AND ' + + `(\`share\`.\`fsentry_id\` = ?${ancestorPaths.length > 0 ? ` OR \`f\`.\`path\` IN (${placeholders})` : ''}) ` + + 'ORDER BY `share`.`id`', + [fsentryId, ...ancestorPaths], + ); + return rows.map((r) => this.#normalizeRow(r)); + } + + async countByHolder(holderUserId) { + const rows = await this.clients.db.read( + 'SELECT COUNT(*) AS `count` FROM `share` WHERE `holder_user_id` = ?', + [holderUserId], + ); + return Number(rows[0]?.count ?? 0); + } + // -- Writes ------------------------------------------------------- async create({ issuerUserId, recipientEmail, data }) { @@ -75,6 +203,129 @@ export class ShareStore extends PuterStore { return this.getByUid(uid); } + /** + * Record an active share, or move an existing one to a new mode. Keyed on + * (holder, fsentry, issuer), so two people with manage rights keep their + * own rows. One statement, so concurrent shares of the same triple settle + * on one row rather than one of them failing the unique key. + * + * @param {object} input + * @param {number} input.issuerUserId + * @param {number} input.holderUserId + * @param {number} input.fsentryId + * @param {string} input.mode + * @param {string | null} [input.recipientEmail] + * @param {string | null} [input.issuerAppUid] + */ + async upsertActive({ + issuerUserId, + holderUserId, + fsentryId, + mode, + recipientEmail = null, + issuerAppUid = null, + }) { + if (!issuerUserId || !holderUserId || !fsentryId || !mode) { + throw new Error( + 'upsertActive: issuerUserId, holderUserId, fsentryId and mode are required', + ); + } + + // A share issued through an app is attributed to the user, because the + // grant is theirs. `data` records which app asked for it, so the owner + // can tell an app-issued share from one they made themselves. + const data = JSON.stringify( + issuerAppUid ? { issuedByApp: issuerAppUid } : {}, + ); + await this.clients.db.write( + 'INSERT INTO `share` (`uid`, `issuer_user_id`, `recipient_email`, ' + + '`holder_user_id`, `fsentry_id`, `mode`, `data`, `applied_at`) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ' + + this.clients.db.upsertClause( + ['holder_user_id', 'fsentry_id', 'issuer_user_id'], + ['mode', 'data'], + ), + [ + uuidv4(), + issuerUserId, + recipientEmail ?? '', + holderUserId, + fsentryId, + mode, + data, + mode, + data, + ], + ); + return this.getActive({ holderUserId, fsentryId, issuerUserId }); + } + + /** + * @param {object} input + * @param {number} input.holderUserId + * @param {number} input.fsentryId + * @param {number} input.issuerUserId + */ + async getActive({ holderUserId, fsentryId, issuerUserId }) { + const rows = await this.clients.db.read( + 'SELECT * FROM `share` WHERE `holder_user_id` = ? AND ' + + '`fsentry_id` = ? AND `issuer_user_id` = ? LIMIT 1', + [holderUserId, fsentryId, issuerUserId], + ); + return this.#normalizeRow(rows[0]) ?? null; + } + + /** + * Drop one active share. Omit `issuerUserId` to clear every issuer's share + * of that node with that holder — what an owner revoking access wants. + * + * @param {object} input + * @param {number} input.holderUserId + * @param {number} input.fsentryId + * @param {number | null} [input.issuerUserId] + */ + async deleteActive({ holderUserId, fsentryId, issuerUserId = null }) { + const scoped = issuerUserId !== null && issuerUserId !== undefined; + const result = await this.clients.db.write( + 'DELETE FROM `share` WHERE `holder_user_id` = ? AND `fsentry_id` = ?' + + (scoped ? ' AND `issuer_user_id` = ?' : ''), + scoped + ? [holderUserId, fsentryId, issuerUserId] + : [holderUserId, fsentryId], + ); + return (result?.affectedRows ?? result?.changes ?? 0) > 0; + } + + /** + * Claim a pending invite for the user who signed up. Updates rather than + * deletes, so the share survives as an index row. + * + * @param {object} input + * @param {string} input.uid + * @param {number} input.holderUserId + * @param {number | null} [input.fsentryId] + * @param {string | null} [input.mode] + */ + async applyPending({ uid, holderUserId, fsentryId = null, mode = null }) { + if (!uid || !holderUserId) { + throw new Error('applyPending: uid and holderUserId are required'); + } + const result = await this.clients.db.write( + 'UPDATE `share` SET `holder_user_id` = ?, `applied_at` = CURRENT_TIMESTAMP' + + (fsentryId === null ? '' : ', `fsentry_id` = ?') + + (mode === null ? '' : ', `mode` = ?') + + ' WHERE `uid` = ? AND `holder_user_id` IS NULL', + [ + holderUserId, + ...(fsentryId === null ? [] : [fsentryId]), + ...(mode === null ? [] : [mode]), + uid, + ], + ); + if ((result?.affectedRows ?? result?.changes ?? 0) === 0) return null; + return this.getByUid(uid); + } + async deleteByUid(uid) { const result = await this.clients.db.write( 'DELETE FROM `share` WHERE `uid` = ?', @@ -91,6 +342,42 @@ export class ShareStore extends PuterStore { return (result?.affectedRows ?? result?.changes ?? 0) > 0; } + // -- Daily quota -------------------------------------------------- + // Counted in KV, not by querying `share`: the ceiling is on shares + // *created*, so a COUNT of live rows would let a revoke recycle the slot. + + /** @param {number} userId */ + async getDailyShareCount(userId) { + const { res } = await this.stores.kv.get({ + key: this.#dailyQuotaKey(userId), + }); + const count = /** @type {{ count?: unknown } | null} */ (res)?.count; + return typeof count === 'number' ? count : 0; + } + + /** + * @param {number} userId + * @param {number} [amount] + * @returns {Promise} The count after incrementing + */ + async incrementDailyShareCount(userId, amount = 1) { + const { res } = await this.stores.kv.incr({ + key: this.#dailyQuotaKey(userId), + pathAndAmountMap: { count: amount }, + // Two days, so a counter written just before midnight still ages + // out on its own. + expireAt: Math.floor(Date.now() / 1000) + 2 * 24 * 60 * 60, + }); + const count = /** @type {{ count?: unknown } | null} */ (res)?.count; + return typeof count === 'number' ? count : amount; + } + + /** @param {number} userId */ + #dailyQuotaKey(userId) { + const day = new Date().toISOString().slice(0, 10); + return `share:quota:${userId}:${day}`; + } + // -- Internals ---------------------------------------------------- #normalizeRow(row) { diff --git a/src/backend/stores/share/ShareStore.test.js b/src/backend/stores/share/ShareStore.test.js index c2342b277e..7d0053859f 100644 --- a/src/backend/stores/share/ShareStore.test.js +++ b/src/backend/stores/share/ShareStore.test.js @@ -233,4 +233,276 @@ describe('ShareStore', () => { false, ); }); + + // -- active shares (the index) ------------------------------------- + + describe('active shares', () => { + let holder; + + const makeEntry = async (owner) => { + const uuid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)', + [ + uuid, + `f-${uuid.slice(0, 8)}`, + `/x/${uuid}`, + owner.id, + Math.floor(Date.now() / 1000), + ], + ); + const rows = await server.clients.db.read( + 'SELECT `id` FROM `fsentries` WHERE `uuid` = ?', + [uuid], + ); + return { id: Number(rows[0].id), uuid }; + }; + + beforeAll(async () => { + holder = await makeUser(); + }); + + it('finds a subtree share even when the descendant has no path yet', async () => { + const now = Math.floor(Date.now() / 1000); + const dirUuid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 1, ?)', + [dirUuid, `d-${dirUuid.slice(0, 8)}`, `/x/${dirUuid}`, issuer.id, now], + ); + const dirRows = await server.clients.db.read( + 'SELECT `id` FROM `fsentries` WHERE `uuid` = ?', + [dirUuid], + ); + const dirId = Number(dirRows[0].id); + + const childUuid = uuidv4(); + await server.clients.db.write( + 'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, NULL, ?, 0, ?, ?, ?)', + [childUuid, `f-${childUuid.slice(0, 8)}`, issuer.id, now, dirId, dirUuid], + ); + const childRows = await server.clients.db.read( + 'SELECT `id` FROM `fsentries` WHERE `uuid` = ?', + [childUuid], + ); + const childId = Number(childRows[0].id); + + await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: childId, + mode: 'read', + }); + + const rows = await store.listByFsentrySubtree(dirId); + expect( + rows.some((r) => Number(r.fsentry_id) === childId), + ).toBe(true); + }); + + it('records an active share and lists it for the holder', async () => { + const entry = await makeEntry(issuer); + const created = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + + expect(created.holder_user_id).toBe(holder.id); + expect(created.fsentry_id).toBe(entry.id); + expect(created.mode).toBe('read'); + expect(created.applied_at).toBeTruthy(); + + const page = await store.listByHolder(holder.id); + expect(page.items.map((r) => r.uid)).toContain(created.uid); + }); + + it('moves an existing share to a new mode instead of duplicating it', async () => { + const entry = await makeEntry(issuer); + const first = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + const second = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'write', + }); + + expect(second.uid).toBe(first.uid); + expect(second.mode).toBe('write'); + expect(await store.listByFsentry(entry.id)).toHaveLength(1); + }); + + it('keeps a separate row per issuer on the same node', async () => { + const entry = await makeEntry(issuer); + await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + await store.upsertActive({ + issuerUserId: otherIssuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'write', + }); + + const rows = await store.listByFsentry(entry.id); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.issuer_user_id).sort()).toEqual( + [issuer.id, otherIssuer.id].sort(), + ); + }); + + it('deletes one issuer share, or every issuer share for the holder', async () => { + const entry = await makeEntry(issuer); + await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + await store.upsertActive({ + issuerUserId: otherIssuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + + expect( + await store.deleteActive({ + holderUserId: holder.id, + fsentryId: entry.id, + issuerUserId: issuer.id, + }), + ).toBe(true); + expect(await store.listByFsentry(entry.id)).toHaveLength(1); + + expect( + await store.deleteActive({ + holderUserId: holder.id, + fsentryId: entry.id, + }), + ).toBe(true); + expect(await store.listByFsentry(entry.id)).toEqual([]); + }); + + it('retires the share when the file is deleted', async () => { + const entry = await makeEntry(issuer); + const created = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + + await server.clients.db.write( + 'DELETE FROM `fsentries` WHERE `id` = ?', + [entry.id], + ); + + // The cascade is what stops a deleted file lingering in the + // recipient's listing forever. + expect(await store.getByUid(created.uid)).toBeNull(); + }); + + it('paginates by keyset and stops without a trailing cursor', async () => { + const pageHolder = await makeUser(); + const uids = []; + for (let i = 0; i < 5; i++) { + const entry = await makeEntry(issuer); + const row = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: pageHolder.id, + fsentryId: entry.id, + mode: 'read', + }); + uids.push(row.uid); + } + + const seen = []; + let cursor; + for (let guard = 0; guard < 10; guard++) { + const page = await store.listByHolder(pageHolder.id, { + limit: 2, + cursor, + }); + seen.push(...page.items.map((r) => r.uid)); + cursor = page.cursor; + if (!cursor) break; + } + + expect(seen).toEqual(uids); + expect(cursor).toBeUndefined(); + expect(await store.countByHolder(pageHolder.id)).toBe(5); + }); + + it('never returns another holder rows', async () => { + const stranger = await makeUser(); + const entry = await makeEntry(issuer); + await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + + const page = await store.listByHolder(stranger.id); + expect(page.items).toEqual([]); + }); + + it('claims a pending invite without dropping the row', async () => { + const entry = await makeEntry(issuer); + const pending = await store.create({ + issuerUserId: issuer.id, + recipientEmail: `pending-${uuidv4()}@test.local`, + data: {}, + }); + + const applied = await store.applyPending({ + uid: pending.uid, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + + expect(applied.holder_user_id).toBe(holder.id); + expect(applied.mode).toBe('read'); + expect(applied.applied_at).toBeTruthy(); + // Second claim finds nothing left to claim. + expect( + await store.applyPending({ + uid: pending.uid, + holderUserId: holder.id, + }), + ).toBeNull(); + }); + + it('excludes pending invites from a holder listing', async () => { + const freshHolder = await makeUser(); + await store.create({ + issuerUserId: issuer.id, + recipientEmail: `unclaimed-${uuidv4()}@test.local`, + data: {}, + }); + + const page = await store.listByHolder(freshHolder.id); + expect(page.items).toEqual([]); + }); + + it('rejects an incomplete active share', async () => { + await expect( + store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + mode: 'read', + }), + ).rejects.toThrow('are required'); + }); + }); }); diff --git a/src/backend/stores/user/UserStore.test.ts b/src/backend/stores/user/UserStore.test.ts index eb6ca27528..869459f139 100644 --- a/src/backend/stores/user/UserStore.test.ts +++ b/src/backend/stores/user/UserStore.test.ts @@ -436,6 +436,10 @@ describe('UserStore batched and uncached lookups', () => { expect((await server.stores.user.getByIds(null as never)).size).toBe(0); }); + + + + it('getByIds serves warm ids from cache and cold ids from the database', async () => { const warm = await makeUser(); const cold = await makeUser(); diff --git a/src/backend/types.ts b/src/backend/types.ts index 62dfece285..bfd2702198 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -825,6 +825,21 @@ interface IConfigOptional { /** When true, ACL grants read/list/see on `//Public` to any actor. */ enable_public_folders: boolean; + /** + * Ceiling on how many shares one user may create per UTC day. An abuse + * bound, not an accounting one — it exists so a script can't blanket other + * accounts with unwanted items and the notifications that follow. Omit to + * use the built-in default. + */ + share_daily_limit?: number; + + /** + * Ceiling on recipients, and on items, in a single share request. Bounds + * the fan-out one call can trigger; the daily limit bounds the total. + */ + share_max_recipients?: number; + share_max_items?: number; + // -- Storage / S3 ------------------------------------------------ /** S3 storage config (local fauxqs or remote). */ diff --git a/src/backend/util/fileSigning.ts b/src/backend/util/fileSigning.ts index 50ed086588..0fdf224666 100644 --- a/src/backend/util/fileSigning.ts +++ b/src/backend/util/fileSigning.ts @@ -83,6 +83,16 @@ function signaturesEqual(provided: string, expected: string): boolean { return timingSafeEqual(a, b); } +/** + * Lifetime for a signature over an entry the signer doesn't own. + * + * `verifySignature` checks the signature and expiry, never the ACL, so a URL + * handed to a recipient keeps working after their access is revoked. This is + * what bounds that window; the durable fix is a per-entry signature epoch the + * owner can bump. + */ +export const NON_OWNER_SIGNATURE_TTL_SECONDS = 60 * 60; + /** * Produce a signed-URL object. The default `expires` timestamp uses a * ~317k-year TTL (effectively permanent) — existing clients depend on that diff --git a/src/docs/src/FS.md b/src/docs/src/FS.md index 68d505bb31..031b875fb3 100644 --- a/src/docs/src/FS.md +++ b/src/docs/src/FS.md @@ -9,7 +9,7 @@ It comes with a comprehensive but familiar file system operations including writ With Puter.js, you don't need to worry about setting up storage infrastructure such as configuring buckets, managing CDNs, or ensuring availability, since everything is handled for you. Additionally, with the [User-Pays Model](/user-pays-model/), you don't have to worry about storage or bandwidth costs, as users of your application cover their own usage. -
Need to share data across users? Each user's files live in their own account, so one user can't read another's data. To keep centralized files that every user reads from and writes to, use a Serverless Worker — its code can act on the worker owner's resources, giving all users one shared backend.
+
Need to share data across users? Each user's files live in their own account, so one user can't read another's by default. To hand specific items to specific people, use puter.fs.share(). To keep centralized files that every user reads from and writes to, use a Serverless Worker — its code can act on the worker owner's resources, giving all users one shared backend.
## Features @@ -307,6 +307,10 @@ These cloud storage features are supported out of the box when using Puter.js: - **[`puter.fs.delete()`](/FS/delete/)** - Delete a file or directory - **[`puter.fs.upload()`](/FS/upload/)** - Upload a file from the local system - **[`puter.fs.getReadURL()`](/FS/getReadURL/)** - Generate a URL that can be used to read a file +- **[`puter.fs.share()`](/FS/share/)** - Give another user access to a file or directory +- **[`puter.fs.unshare()`](/FS/unshare/)** - Withdraw a user's access +- **[`puter.fs.listShared()`](/FS/listShared/)** - List what others have shared with you +- **[`puter.fs.getShares()`](/FS/getShares/)** - List who has access to an item ## Examples diff --git a/src/docs/src/FS/getShares.md b/src/docs/src/FS/getShares.md new file mode 100644 index 0000000000..bceffc008d --- /dev/null +++ b/src/docs/src/FS/getShares.md @@ -0,0 +1,84 @@ +--- +title: puter.fs.getShares() +description: List who has access to a shared file or directory. +platforms: [websites, apps, nodejs, workers] +--- + +This method lists who can reach a file or directory you own, or one you have `manage` access to. + +> **What an app can share.** An app never gets more reach than it was given. It +> can share its own AppData, and files the user specifically granted it, at up +> to the level of access it holds itself — so an app with read access can grant +> read, and nothing more. Files its user owns but never handed to the app stay +> out of reach, and `listShared()` shows an app only the shares it can reach. +> Shares an app creates are attributed to the user and carry `issuedByApp`, so +> the owner can tell them apart in [`getShares()`](/FS/getShares/). + +## Syntax + +```js +puter.fs.getShares(path) +puter.fs.getShares(options) +``` + +## Parameters + +#### `path` (String) (required) + +The path to the file or directory. If `path` is not absolute, it will be resolved relative to the app's root directory. + +#### `options` (Object) (optional) + +An object with the following properties: + +- `path` (String) - The item. Required when passing options as the only argument. +- `uid` (String) - The item, by UID. Can be used instead of `path`. + +## Return value + +A `Promise` that resolves to an array of share objects, each with `uid`, `mode`, `path`, `entryUid`, `isDir`, `issuer`, `holder`, `inheritedFrom`, `issuedByApp`, `modified` and `size`. + +`issuedByApp` is the UID of the app that asked for the share, or `null` when a person made it directly. + +`inheritedFrom` is the path of the shared ancestor an access comes from, or `null` when the share is on the item itself. Like `path`, it is masked when you are not the owner. Access inherited from a parent folder is **managed on that folder** — withdrawing it here is not possible, because the grant does not live on this item. + +The list includes shares granted by **anyone** holding `manage` on the item, not only your own. That is how an owner sees what someone they trusted has re-shared. + +If you cannot see the item at all, this rejects the same way a missing file would — it will not confirm that the item exists. + +## Examples + +See who can reach a file + +```html;fs-getShares + + + + + + +``` + +Withdraw everyone's access + +```js +const shares = await puter.fs.getShares('report.txt'); +for (const share of shares) { + await puter.fs.unshare('report.txt', share.holder); +} +``` + +## Related + +- [`puter.fs.share()`](/FS/share/) - Grant access +- [`puter.fs.unshare()`](/FS/unshare/) - Withdraw access diff --git a/src/docs/src/FS/listShared.md b/src/docs/src/FS/listShared.md new file mode 100644 index 0000000000..85d12740bb --- /dev/null +++ b/src/docs/src/FS/listShared.md @@ -0,0 +1,93 @@ +--- +title: puter.fs.listShared() +description: List the files and directories other users have shared with you. +platforms: [websites, apps, nodejs, workers] +--- + +This method lists what other Puter users have shared with you, a page at a time. + +> **What an app can share.** An app never gets more reach than it was given. It +> can share its own AppData, and files the user specifically granted it, at up +> to the level of access it holds itself — so an app with read access can grant +> read, and nothing more. Files its user owns but never handed to the app stay +> out of reach, and `listShared()` shows an app only the shares it can reach. +> Shares an app creates are attributed to the user and carry `issuedByApp`, so +> the owner can tell them apart in [`getShares()`](/FS/getShares/). + +## Syntax + +```js +puter.fs.listShared() +puter.fs.listShared(options) +``` + +## Parameters + +#### `options` (Object) (optional) + +An object with the following properties: + +- `limit` (Number) - Maximum shares per page. +- `cursor` (String) - Continuation token from a previous page. +- `includeTotal` (Boolean) - Include the total count in the response. Defaults to `false`. + +## Return value + +A `Promise` that resolves to an object with: + +- `items` (Array) - The shares on this page. Each has `uid`, `mode`, `path`, `entryUid`, `isDir`, `name`, `type`, `thumbnail`, `owner`, `issuer`, `holder`, `modified` and `size`. A share row has no directory listing behind it, so `name`, `type` and `thumbnail` are carried on the row itself for rendering. +- `cursor` (String) - Pass to the next call to get the following page. **Present only while more pages remain.** +- `total` (Number) - Present only when `includeTotal` was set. + +Iterate until `cursor` is absent rather than comparing `items.length` to `limit`. A page can come back short — items you can no longer see are filtered out after the page is read — while more pages still remain. + +Items shared with you appear at a **masked path**, `///`, where `` stands in for wherever the owner keeps the item. Pass that path back to any `puter.fs` method and it resolves normally; what it does not tell you is the folder the item lives in, or what sits beside it. Your own items are never listed here. + +## Examples + +List everything shared with you + +```html;fs-listShared + + + + + + +``` + +Page through every share + +```js +let cursor; +const all = []; +do { + const page = await puter.fs.listShared({ limit: 50, cursor }); + all.push(...page.items); + cursor = page.cursor; +} while (cursor); +``` + +Open a file someone shared with you + +```js +const page = await puter.fs.listShared(); +const shared = page.items.find((item) => !item.isDir); +if (shared) { + const blob = await puter.fs.read(shared.path); + puter.print(await blob.text()); +} +``` + +## Related + +- [`puter.fs.share()`](/FS/share/) - Grant access +- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item you manage diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md new file mode 100644 index 0000000000..e16709efe7 --- /dev/null +++ b/src/docs/src/FS/share.md @@ -0,0 +1,127 @@ +--- +title: puter.fs.share() +description: Give another Puter user access to a file or directory. +platforms: [websites, apps, nodejs, workers] +--- + +This method gives another Puter user access to a file or directory you own, or one you have been given `manage` access to. + +> **What an app can share.** An app never gets more reach than it was given. It +> can share its own AppData, and files the user specifically granted it, at up +> to the level of access it holds itself — so an app with read access can grant +> read, and nothing more. Files its user owns but never handed to the app stay +> out of reach, and `listShared()` shows an app only the shares it can reach. +> Shares an app creates are attributed to the user and carry `issuedByApp`, so +> the owner can tell them apart in [`getShares()`](/FS/getShares/). + +## Syntax + +```js +puter.fs.share(path, recipient) +puter.fs.share(path, recipient, mode) +puter.fs.share(options) +``` + +## Parameters + +#### `path` (String) (required) + +The path to the file or directory to share. +If `path` is not absolute, it will be resolved relative to the app's root directory. + +#### `recipient` (String | Object | Array) (required) + +Who to share with. A string containing `@` is treated as an email address, and any other string as a username. You can also pass `{ email }` or `{ username }`, or an array to share with several people at once. + +#### `mode` (String) (optional) + +How much access to grant. Defaults to `'read'`. + +- `'read'` - Read the item. +- `'write'` - Read and change the item. Does **not** allow re-sharing it. +- `'manage'` - Everything `'write'` allows, plus re-sharing the item with other people. +- `'list'`, `'see'` - Weaker than `read`; useful for making an item discoverable without exposing its contents. + +#### `options` (Object) (optional) + +An object with the following properties: + +- `path` (String) - Item to share. Required when passing options as the only argument. +- `uid` (String) - Item to share, by UID. Can be used instead of `path`. +- `paths` (Array) - Several items to share in one call. +- `recipient` (String | Object | Array) - Who to share with. +- `mode` (String) - Access to grant. Defaults to `'read'`. + +## Return value + +A `Promise` that resolves to an array of share objects, one per recipient/item pair that succeeded. Each has: + +- `uid` (String) - Identifier for this share. +- `mode` (String) - Access the recipient now has. +- `path` (String) - Path of the shared item, masked when you do not own it (see [`listShared()`](/FS/listShared/)). +- `entryUid` (String) - UID of the shared item. +- `isDir` (Boolean) - Whether the shared item is a directory. +- `issuer` (String) - Username of whoever granted the share. +- `holder` (String) - Username of whoever received it. +- `inheritedFrom` (String) - Path of the shared ancestor this access comes from, or `null` when the share is on the item itself. +- `modified` (Number) - Last-modified time of the item, in unix seconds. +- `size` (Number) - Size of the item in bytes; `null` for a directory. + +Sharing the same item with the same person again **replaces** their access rather than adding a second share, so raising someone from `read` to `write` is just another call. + +If some recipients succeed and others fail, the promise resolves with the ones that worked. It rejects only when every pair failed. + +## Examples + +Share a file with another user + +```html;fs-share + + + + + + +``` + +Let someone edit, and let someone else re-share + +```js +// An editor can change the file but cannot pass it on. +await puter.fs.share('report.txt', 'editor@example.com', 'write'); + +// A manager can edit it AND share it with other people. +await puter.fs.share('report.txt', 'manager@example.com', 'manage'); +``` + +Share one item with several people + +```js +await puter.fs.share({ + path: 'report.txt', + recipient: ['a@example.com', 'b@example.com'], + mode: 'read', +}); +``` + +## Live updates + +Changes inside a shared item are not pushed to recipients in real time — +filesystem socket events go to the item's owner only. A client that shows +shared content and needs it current should re-read it (`readdir`/`stat`) +when freshness matters, for example on focus or an explicit refresh. + +## Related + +- [`puter.fs.unshare()`](/FS/unshare/) - Withdraw access +- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item +- [`puter.fs.listShared()`](/FS/listShared/) - See what others have shared with you diff --git a/src/docs/src/FS/unshare.md b/src/docs/src/FS/unshare.md new file mode 100644 index 0000000000..8158473679 --- /dev/null +++ b/src/docs/src/FS/unshare.md @@ -0,0 +1,89 @@ +--- +title: puter.fs.unshare() +description: Withdraw a user's access to a shared file or directory. +platforms: [websites, apps, nodejs, workers] +--- + +This method withdraws a user's access to a file or directory. + +> **What an app can share.** An app never gets more reach than it was given. It +> can share its own AppData, and files the user specifically granted it, at up +> to the level of access it holds itself — so an app with read access can grant +> read, and nothing more. Files its user owns but never handed to the app stay +> out of reach, and `listShared()` shows an app only the shares it can reach. +> Shares an app creates are attributed to the user and carry `issuedByApp`, so +> the owner can tell them apart in [`getShares()`](/FS/getShares/). + +## Syntax + +```js +puter.fs.unshare(path, recipient) +puter.fs.unshare(options) +``` + +## Parameters + +#### `path` (String) (required) + +The path to the file or directory. If `path` is not absolute, it will be resolved relative to the app's root directory. + +#### `recipient` (String | Object) (required) + +Whose access to withdraw. A string containing `@` is treated as an email address, and any other string as a username. + +Pass **yourself** to leave a share someone else gave you. + +#### `options` (Object) (optional) + +An object with the following properties: + +- `path` (String) - The item. Required when passing options as the only argument. +- `uid` (String) - The item, by UID. Can be used instead of `path`. +- `recipient` (String | Object) - Whose access to withdraw. + +## Return value + +A `Promise` that resolves to `{ revoked }`, where `revoked` is how many grants were actually removed. It is `0` when there was nothing to withdraw, which is not an error. + +## Who can withdraw what + +- The item's **owner** can withdraw any share of it, whoever granted it. +- Anyone else can withdraw the shares **they** granted. +- **Anyone** can withdraw their own access, whoever granted it. + +An item's owner cannot be removed from their own item. + +Withdrawing someone's access also withdraws whatever **they** re-shared of that item. Their authority to grant came from the access being removed, so it cannot outlive it. + +## Examples + +Stop sharing a file + +```html;fs-unshare + + + + + + +``` + +Leave a share someone gave you + +```js +const me = await puter.auth.getUser(); +await puter.fs.unshare('/alice/report.txt', me.username); +``` + +## Related + +- [`puter.fs.share()`](/FS/share/) - Grant access +- [`puter.fs.getShares()`](/FS/getShares/) - See who can reach an item diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index 123dbf2432..3bdad34b86 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -361,6 +361,38 @@ let sidebar = [ source: '/FS/upload.md', path: '/FS/upload', }, + { + title: 'share()', + page_title: 'puter.fs.share()', + title_tag: 'puter.fs.share()', + icon: '/assets/img/function.svg', + source: '/FS/share.md', + path: '/FS/share', + }, + { + title: 'unshare()', + page_title: 'puter.fs.unshare()', + title_tag: 'puter.fs.unshare()', + icon: '/assets/img/function.svg', + source: '/FS/unshare.md', + path: '/FS/unshare', + }, + { + title: 'listShared()', + page_title: 'puter.fs.listShared()', + title_tag: 'puter.fs.listShared()', + icon: '/assets/img/function.svg', + source: '/FS/listShared.md', + path: '/FS/listShared', + }, + { + title: 'getShares()', + page_title: 'puter.fs.getShares()', + title_tag: 'puter.fs.getShares()', + icon: '/assets/img/function.svg', + source: '/FS/getShares.md', + path: '/FS/getShares', + }, ], }, { diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index dc569ae6d2..07c9ff0162 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -36,28 +36,10 @@ import UIItemPropertiesModal from './UIItemPropertiesModal.js'; import { dedupedName } from './dedupedName.js'; import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js'; -const icons = { - document: ``, - files: ``, - folder: ``, - more: ``, - // Header action icons use the Material Symbols wght300 cut (one step - // lighter than the default 400) to match the thinned nav arrows. - newFolder: ``, - upload: ``, - trash: ``, - download: ``, - cut: ``, - copy: ``, - restore: ``, - list: ``, - grid: ``, - gridSmall: ``, - sort: ``, - select: ``, - done: ``, - worker: ``, -}; +import { icons } from '../../helpers/actionIcons.js'; +import list_all_shared from '../../helpers/list_all_shared.js'; +import { remember_shared_roots } from '../../helpers/shared_access.js'; +import { parent_path_for, shared_crumbs_for } from '../../helpers/share_paths.js'; const { html_encode, SelectionArea } = window; @@ -99,6 +81,7 @@ const TabFiles = {
  • Pictures
  • Public
  • Videos
  • +
  • ${i18n('shared')}
  • Trash
  • @@ -705,12 +688,9 @@ const TabFiles = { if ( $selectedRow.length > 0 ) { e.preventDefault(); e.stopPropagation(); - const $nameEditor = $selectedRow.find('.item-name-editor'); - const $itemName = $selectedRow.find('.item-name'); - if ( $nameEditor.length > 0 ) { - $itemName.hide(); - $nameEditor.show().addClass('item-name-editor-active').focus().select(); - } + // The shared editor carries the guards (immutable, trash, + // items you hold no write on) this handler used to skip. + window.activate_item_name_editor($selectedRow[0]); } return false; } @@ -1217,9 +1197,11 @@ const TabFiles = { // Up button $(el_window_navbar_up_btn).on('click', function () { - if ( _this.currentPath === '/' ) return; + if ( _this.currentPath === '/' || _this.currentPath === window.shared_path ) return; - const target_path = path.resolve(path.join(_this.currentPath, '..')); + // Above a shared item is its owner's folder, which is not ours to + // open — `parent_path_for` sends us to Shared instead. + const target_path = parent_path_for(path.resolve(_this.currentPath)); _this.pushNavHistory(target_path); _this.renderDirectory(target_path); }); @@ -1258,8 +1240,8 @@ const TabFiles = { }); makeNavBtnSpringLoaded(el_window_navbar_up_btn, () => { - if ( _this.currentPath === '/' ) return false; - const target_path = path.resolve(path.join(_this.currentPath, '..')); + if ( _this.currentPath === '/' || _this.currentPath === window.shared_path ) return false; + const target_path = parent_path_for(path.resolve(_this.currentPath)); if ( ! _this.canSpringLoadInto(target_path) ) return false; _this.pushNavHistory(target_path); _this.renderDirectory(target_path); @@ -1267,6 +1249,8 @@ const TabFiles = { // New folder button document.querySelector('.new-folder-btn').onclick = () => { + // The Shared view is a query, not a directory. + if ( _this.currentPath === window.shared_path ) return; _this.createFolderInstant(_this.currentPath); }; @@ -1274,6 +1258,7 @@ const TabFiles = { fileInput.onchange = async (e) => { const files = e.target.files; if ( !files || files.length === 0 ) return; + if ( _this.currentPath === window.shared_path ) return; let upload_progress_window; let opid; @@ -2162,9 +2147,32 @@ const TabFiles = { const readdirArg = isPath ? { path: target, consistency: options.consistency || 'eventual' } : { uid: target, consistency: options.consistency || 'eventual' }; + // Shared is a query, not a directory — its rows come from listShared + // and live under their owners' paths. + const isSharedView = target === window.shared_path; let directoryContents; try { - directoryContents = await window.puter.fs.readdir(readdirArg); + directoryContents = isSharedView + ? (await list_all_shared().then((shares) => { + remember_shared_roots(shares); + return shares; + })).map((share) => ({ + uid: share.entryUid, + name: share.name ?? share.path.split('/').pop(), + path: share.path, + is_dir: share.isDir, + // A share row has no fsentry behind it to stat, so the + // listing carries what the icon needs. + type: share.type, + thumbnail: share.thumbnail, + modified: share.modified, + size: share.size, + shared_with_me: true, + share_mode: share.mode, + shared_by: share.issuer, + owner: share.owner, + })) + : await window.puter.fs.readdir(readdirArg); } catch ( err ) { // readdir rejects on any backend error (permission, deleted dir, // network). Without this, renderingDirectory would stay true and @@ -2412,6 +2420,9 @@ const TabFiles = { row.setAttribute("data-uid", file.uid); row.setAttribute("data-is_dir", file.is_dir ? "1" : "0"); row.setAttribute("data-is_trash", file.is_trash ? "1" : "0"); + row.setAttribute("data-shared_with_me", file.shared_with_me ? "1" : "0"); + row.setAttribute("data-share_mode", file.share_mode ?? ''); + row.setAttribute("data-shared_by", file.shared_by ?? ''); row.setAttribute("data-has_website", file.has_website ? "1" : "0"); // setAttribute stores values literally (no HTML parsing), so values must // stay raw — encoding here would leave e.g. `&` inside data-path and @@ -3736,7 +3747,8 @@ const TabFiles = { forwardBtn.removeClass('path-btn-disabled'); } - if ( this.currentPath === '/' ) { + // The Shared view has no parent either — it is a query, not a directory. + if ( this.currentPath === '/' || this.currentPath === window.shared_path ) { upBtn.addClass('path-btn-disabled'); } else { upBtn.removeClass('path-btn-disabled'); @@ -4004,11 +4016,14 @@ const TabFiles = { const isTrashFolder = targetPath === window.trash_path; const isTrashedPath = targetPath.startsWith(`${window.trash_path}/`); + // The Shared view is a query, not a directory — nothing can be + // created, pasted or uploaded "into" it. + const isSharedView = targetPath === window.shared_path; const items = []; // New submenu (folder, text document, etc.) - not available in Trash // We create a custom "New" submenu to handle folder creation with refresh and rename activation - if ( ! isTrashFolder ) { + if ( ! isTrashFolder && ! isSharedView ) { const newMenuItems = new_context_menu_item(targetPath, null); // Override the "New Folder" onClick to refresh and activate rename @@ -4099,7 +4114,7 @@ const TabFiles = { } // Paste - only if clipboard has items and not in Trash - if ( !isTrashFolder && window.clipboard && window.clipboard.length > 0 ) { + if ( !isTrashFolder && !isSharedView && window.clipboard && window.clipboard.length > 0 ) { items.push({ html: i18n('paste'), onClick: async function () { @@ -4139,7 +4154,7 @@ const TabFiles = { } // Upload Here - not available in Trash - if ( ! isTrashFolder ) { + if ( ! isTrashFolder && ! isSharedView ) { items.push({ html: i18n('upload'), onClick: function () { @@ -4471,8 +4486,10 @@ const TabFiles = { return; } - // Block uploads to trash - if ( _this.currentPath === window.trash_path ) { + // Block uploads to trash, and to the Shared view — a query, + // not a directory. + if ( _this.currentPath === window.trash_path || + _this.currentPath === window.shared_path ) { return; } @@ -4623,6 +4640,22 @@ const TabFiles = { const dirs = (abs_path === '/' ? [''] : abs_path.split('/')); const dirpaths = (abs_path === '/' ? ['/'] : []); const path_seperator_html = ``; + + // The Shared view is a query, not a directory — one crumb, no ancestry. + if ( abs_path === window.shared_path ) { + return `${path_seperator_html}${html_encode(i18n('shared'))}`; + } + + // Someone else's tree is shown from the share down, not from their home. + const shared = shared_crumbs_for(abs_path); + if ( shared ) { + let str = `${path_seperator_html}${html_encode(i18n('shared'))}`; + for ( const crumb of shared ) { + str += `${path_seperator_html}${html_encode(crumb.label)}`; + } + return str; + } + if ( dirs.length > 1 ) { for ( let i = 0; i < dirs.length; i++ ) { dirpaths[i] = ''; diff --git a/src/gui/src/UI/UIItem.js b/src/gui/src/UI/UIItem.js index 9919100a32..033dc090c2 100644 --- a/src/gui/src/UI/UIItem.js +++ b/src/gui/src/UI/UIItem.js @@ -24,6 +24,7 @@ import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequir import UIContextMenu from './UIContextMenu.js'; import UIAlert from './UIAlert.js'; import UIWindowPublishWorker from './UIWindowPublishWorker.js'; +import UIWindowShare from './UIWindowShare.js'; import path from '../lib/path.js'; import truncate_filename from '../helpers/truncate_filename.js'; import launch_app from '../helpers/launch_app.js'; @@ -31,6 +32,8 @@ import open_item from '../helpers/open_item.js'; import publish_as_website from '../helpers/publish_as_website.js'; import mime from '../lib/mime.js'; import { isWeblinkName, weblinkChangeIconMenuItem } from '../helpers/weblink.js'; +import { is_owned_by_me } from '../helpers/path_owner.js'; +import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/shared_access.js'; const AI_APP_NAME = 'ai'; @@ -129,6 +132,10 @@ async function UIItem (options) { options.is_selected = options.is_selected ?? false; options.is_shortcut = options.is_shortcut ?? 0; options.is_trash = options.is_trash ?? false; + options.shared_with_me = options.shared_with_me ?? false; + options.share_mode = options.share_mode ?? ''; + options.shared_by = options.shared_by ?? ''; + options.owner = options.owner ?? ''; options.metadata = options.metadata ?? ''; options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false; options.shortcut_to = options.shortcut_to ?? ''; @@ -161,6 +168,10 @@ async function UIItem (options) { data-uid="${options.uid}" data-is_dir="${options.is_dir ? 1 : 0}" data-is_trash="${options.is_trash ? 1 : 0}" + data-shared_with_me="${options.shared_with_me ? 1 : 0}" + data-share_mode="${html_encode(options.share_mode)}" + data-shared_by="${html_encode(options.shared_by)}" + data-owner="${html_encode(options.owner)}" data-has_website="${show_website_badge ? 1 : 0 }" data-website_url = "${website_url ? html_encode(website_url) : ''}" data-immutable="${options.immutable}" @@ -1134,6 +1145,26 @@ async function UIItem (options) { // ------------------------------------------------------- else { const is_trash = $(el_item).attr('data-path') === window.trash_path || $(el_item).attr('data-shortcut_to_path') === window.trash_path; + // Has its own share, so it is a row the Shared view listed. + const is_shared_root = $(el_item).attr('data-shared_with_me') === '1'; + // Someone else's, however we got here — including items reached by + // opening a shared folder, which carry no share markers. + const is_not_mine = !is_owned_by_me($(el_item).attr('data-path')); + // `manage` inherits downwards, so a file inside a folder you manage + // counts too — the row itself only carries a mode at a shared root. + const can_manage_share = + $(el_item).attr('data-share_mode') === 'manage' + || (await shared_mode_for($(el_item).attr('data-path'))) === 'manage'; + // Moving and deleting go by the holding folder, not by the item. + const may_restructure = !is_not_mine + || await can_restructure($(el_item).attr('data-path')); + // A shared FILE you hold write on is renameable even though it + // can't be moved; a shared folder root is not. + const may_rename = !is_not_mine + || await can_rename( + $(el_item).attr('data-path'), + ['1', 'true'].includes($(el_item).attr('data-is_dir')), + ); const is_shortcut = !! $(el_item).attr('data-shortcut_to_path'); const is_weblink = isWeblinkName($(el_item).attr('data-name')); menu_items = []; @@ -1555,9 +1586,48 @@ async function UIItem (options) { menu_items.push(weblinkChangeIconMenuItem(el_item)); } // ------------------------------------------- + // Share + // ------------------------------------------- + if ( !is_trash && !is_trashed && (!is_not_mine || can_manage_share) ) { + menu_items.push({ + html: i18n('share_ellipsis'), + onClick: async function () { + UIWindowShare({ + path: $(el_item).attr('data-path'), + name: $(el_item).attr('data-name'), + }); + }, + }); + } + // ------------------------------------------- + // Remove from Shared + // ------------------------------------------- + // Someone else owns this, so deleting it would move their file into + // our trash — which the backend refuses. Give up our own access + // instead, which is what "remove it from my view" actually means. + if ( is_shared_root ) { + menu_items.push({ + html: i18n('share_remove_from_shared'), + onClick: async function () { + try { + await puter.fs.unshare( + $(el_item).attr('data-path'), + window.user.username, + ); + // Or mode lookups keep answering for a share we + // just walked away from. + invalidate_shared_roots(); + $(el_item).removeItems(); + } catch (e) { + UIAlert({ message: e?.message ?? i18n('error_unknown_cause') }); + } + }, + }); + } + // ------------------------------------------- // Delete // ------------------------------------------- - if ( $(el_item).attr('data-immutable') === '0' && !is_trashed ) { + if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) { menu_items.push({ html: i18n('delete'), onClick: async function () { @@ -1597,7 +1667,7 @@ async function UIItem (options) { // ------------------------------------------- // Rename // ------------------------------------------- - if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash ) { + if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash && may_rename ) { menu_items.push({ html: i18n('rename'), onClick: function () { @@ -1856,7 +1926,7 @@ $.fn.removeItems = async function (options) { return this; }; -window.activate_item_name_editor = function (el_item) { +window.activate_item_name_editor = async function (el_item) { // files in trash cannot be renamed, the user should be notified with an Alert. if ( $(el_item).attr('data-immutable') !== '0' ) { return; @@ -1866,6 +1936,15 @@ window.activate_item_name_editor = function (el_item) { UIAlert(i18n('items_in_trash_cannot_be_renamed')); return; } + // Someone else's item is renameable only with write on it (files, not + // shared folder roots) — this also covers the click-to-edit and keyboard + // paths, not just the context menu. + else if ( ! await can_rename( + $(el_item).attr('data-path'), + ['1', 'true'].includes($(el_item).attr('data-is_dir')), + ) ) { + return; + } const el_item_name = $(el_item).find('.item-name'); const el_item_name_editor = $(el_item).find('.item-name-editor').get(0); diff --git a/src/gui/src/UI/UIWindow.js b/src/gui/src/UI/UIWindow.js index 713d9e2b80..e0a466c226 100644 --- a/src/gui/src/UI/UIWindow.js +++ b/src/gui/src/UI/UIWindow.js @@ -30,6 +30,8 @@ import launch_app from '../helpers/launch_app.js'; import publish_as_website from '../helpers/publish_as_website.js'; import item_icon from '../helpers/item_icon.js'; +import { parent_path_for, shared_crumbs_for } from '../helpers/share_paths.js'; +import { has_shared_roots } from '../helpers/shared_access.js'; import { is_window_hidden, is_unseen_background_window, user_facing_windows } from '../helpers/window_visibility.js'; const el_body = document.getElementsByTagName('body')[0]; @@ -363,6 +365,7 @@ async function UIWindow (options) { h += `
    ${i18n('pictures')}
    `; h += `
    ${i18n('desktop')}
    `; h += `
    ${i18n('videos')}
    `; + h += `
    ${i18n('shared')}
    `; } else { let items = JSON.parse(window.sidebar_items); // Saved sidebar orders may predate the Home entry — make sure it's always present @@ -395,6 +398,10 @@ async function UIWindow (options) { { icon = window.icons['sidebar-folder-videos.svg']; } + else if ( item.path === window.shared_path ) + { + icon = window.icons['sidebar-folder-shared.svg']; + } else { icon = window.icons['sidebar-folder.svg']; @@ -417,7 +424,7 @@ async function UIWindow (options) { // Forward h += ``; // Up - h += ``; + h += ``; h += ''; // Path h += `
    ${window.navbar_path(options.path, window.user.username)}
    `; @@ -1076,6 +1083,18 @@ async function UIWindow (options) { if ( options.is_dir ) { window.navbar_path_droppable(el_window); window.sidebar_item_droppable(el_window); + + // Saved sidebar orders predate the Shared entry, and unlike Home it + // only matters to users who actually have shares — so append it once + // that's known, rather than backfilling it for everyone. + if ( window.sidebar_items && !JSON.parse(window.sidebar_items).some(item => item.path === window.shared_path) ) { + has_shared_roots().then((has_shares) => { + const el_sidebar = $(el_window).find('.window-sidebar'); + if ( ! has_shares || el_sidebar.length === 0 ) return; + if ( el_sidebar.find(`.window-sidebar-item[data-path="${html_encode(window.shared_path)}"]`).length > 0 ) return; + el_sidebar.append(`
    ${i18n('shared')}
    `); + }); + } // -------------------------------------------------------- // Back button // -------------------------------------------------------- @@ -1224,7 +1243,13 @@ async function UIWindow (options) { // Up button // -------------------------------------------------------- $(el_window_navbar_up_btn).on('click', function (e) { - const target_path = path.resolve(path.join($(el_window).attr('data-path'), '..')); + // The Shared view has no parent — and `path.resolve` would mangle + // its `puter://` form into a navigable-looking garbage path. + const current_path = $(el_window).attr('data-path'); + if ( current_path === window.shared_path ) return; + // Above a shared item is its owner's folder, which is not ours to + // open — `parent_path_for` sends us to Shared instead. + const target_path = parent_path_for(path.resolve(current_path)); // if ctrl/cmd are pressed, open in new window if ( e.ctrlKey || e.metaKey && (target_path !== undefined && target_path !== null) ) { UIWindow({ @@ -1766,7 +1791,8 @@ async function UIWindow (options) { }, drop: function (dragsterEvent, event) { const e = event.originalEvent; - if ( options.is_dir ) { + // The Shared view is a query, not a directory — nowhere to upload. + if ( options.is_dir && $(el_window).attr('data-path') !== window.shared_path ) { // if files were dropped... if ( e.dataTransfer?.items?.length > 0 ) { window.upload_items(e.dataTransfer.items, $(el_window).attr('data-path')); @@ -2532,7 +2558,9 @@ async function UIWindow (options) { }, }); - if ( $(el_window).attr('data-path') !== '/' ) { + // The Shared view is a query, not a directory — nothing can + // be created or pasted "into" it. + if ( $(el_window).attr('data-path') !== '/' && $(el_window).attr('data-path') !== window.shared_path ) { // ------------------------------------------- // - // ------------------------------------------- @@ -3194,6 +3222,10 @@ window.navbar_path_droppable = (el_window) => { if ( $(window.mouseover_window).attr('data-id') !== $(el_window).attr('data-id') ) { return; } + // The Shared view is a query, not a directory — not a drop target. + if ( $(this).attr('data-path') === window.shared_path ) { + return; + } const items_to_move = []; // first item @@ -3291,6 +3323,22 @@ window.navbar_path = (abs_path) => { const dirs = (abs_path === '/' ? [''] : abs_path.split('/')); const dirpaths = (abs_path === '/' ? ['/'] : []); const path_seperator_html = ``; + + // The Shared view is a query, not a directory — one crumb, no ancestry. + if ( abs_path === window.shared_path ) { + return `${path_seperator_html}${html_encode(i18n('shared'))}`; + } + + // Someone else's tree is shown from the share down, not from their home. + const shared = shared_crumbs_for(abs_path); + if ( shared ) { + let str = `${path_seperator_html}${html_encode(i18n('shared'))}`; + for ( const crumb of shared ) { + str += `${path_seperator_html}${html_encode(crumb.label)}`; + } + return str; + } + if ( dirs.length > 1 ) { for ( let i = 0; i < dirs.length; i++ ) { dirpaths[i] = ''; @@ -3348,7 +3396,7 @@ window.update_window_path = async function (el_window, target_path) { } // disabled Up button if this is root - if ( target_path === '/' ) + if ( target_path === '/' || target_path === window.shared_path ) { $(el_window_navbar_up_btn).addClass('window-navbar-btn-disabled'); } @@ -3407,7 +3455,14 @@ window.update_window_path = async function (el_window, target_path) { $(el_window).attr('data-name', html_encode(path.basename(target_path))); // /stat - if ( target_path !== '/' ) { + if ( target_path === window.shared_path ) { + // A query, not a directory — nothing to stat. + $(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`); + $(el_window).attr('data-uid', 'null'); + $(el_window).find('.window-head-title').text(i18n('shared_with_me')); + $(el_window).find('.window-head-icon').attr('src', window.icons['shared.svg']); + } + else if ( target_path !== '/' ) { try { puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => { $(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`); @@ -3500,6 +3555,11 @@ window.sidebar_item_droppable = (el_window) => { if ( $(window.mouseover_window).attr('data-id') !== $(el_window).attr('data-id') ) { return; } + // The Shared view is a query, not a directory — not a drop target. + if ( $(this).attr('data-path') === window.shared_path ) { + $(this).removeClass('window-sidebar-item-drag-active'); + return; + } const items_to_move = []; // first item diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js new file mode 100644 index 0000000000..2b58f3c3dc --- /dev/null +++ b/src/gui/src/UI/UIWindowShare.js @@ -0,0 +1,239 @@ +/* + * 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 . + */ + +import UIWindow from './UIWindow.js'; +import UIAlert from './UIAlert.js'; +import path from '../lib/path.js'; +import { owner_of_path } from '../helpers/path_owner.js'; +import { invalidate_shared_roots } from '../helpers/shared_access.js'; +import { icons } from '../helpers/actionIcons.js'; + +// Offered when granting. The API accepts `see` and `list` too, but they are a +// developer-level distinction with no place in this dialog — a row already set +// to one is shown as-is rather than quietly rounded up to `read`. +const MODES = ['read', 'write', 'manage']; + +const mode_label = (mode) => { + if ( mode === 'write' ) return i18n('share_access_write'); + if ( mode === 'manage' ) return i18n('share_access_manage'); + if ( mode === 'read' ) return i18n('share_access_read'); + return mode; +}; + +const options_for = (current) => { + const modes = MODES.includes(current) ? MODES : [current, ...MODES]; + return modes + .map( + (mode) => + ``, + ) + .join(''); +}; + +/** + * Sharing dialog for one file or directory. + * + * @param {object} options + * @param {string} options.path Item to share. + * @param {string} [options.name] Display name; defaults to the path's basename. + * @param {string} [options.owner] Owner's username; defaults to the first path + * segment, which is not the current user when a `manage` recipient opens this. + */ +async function UIWindowShare (options) { + options = options ?? {}; + const item_path = options.path; + const item_name = options.name ?? path.basename(item_path); + const item_owner = + options.owner ?? owner_of_path(item_path) ?? window.user.username; + + let h = ''; + h += ''; + + // One dialog per item — window-level single_instance would refocus a + // dialog still bound to a different file. + const $existing = $('.window[data-app="share"]').filter( + (_, el) => $(el).attr('data-share-path') === item_path, + ); + if ( $existing.length ) { + $existing.focusWindow(); + return; + } + + const el_window = await UIWindow({ + title: `${i18n('share')} — ${item_name}`, + app: 'share', + icon: window.icons['share-outline.svg'], + uid: null, + is_dir: false, + body_content: h, + has_head: true, + selectable_body: false, + draggable_body: false, + allow_context_menu: false, + is_resizable: false, + is_droppable: false, + init_center: true, + allow_native_ctxmenu: false, + allow_user_select: false, + width: 420, + height: 'auto', + dominant: true, + show_in_taskbar: false, + onAppend: function (this_window) { + $(this_window).find('.share-recipient').get(0)?.focus({ preventScroll: true }); + }, + window_class: 'window-share', + window_css: { height: 'initial' }, + body_css: { width: 'initial', padding: '0', 'background-color': 'rgb(245 247 249)' }, + }); + $(el_window).attr('data-share-path', item_path); + + const $error = $(el_window).find('.form-error-msg'); + const $success = $(el_window).find('.form-success-msg'); + const $list = $(el_window).find('.share-list'); + + const show_error = (message) => { + $success.hide(); + $error.html(html_encode(message)).show(); + }; + + const show_success = (message) => { + $error.hide(); + $success.html(message).show(); + }; + + const render = (shares) => { + let rows = ''; + // The owner's access comes from owning the item, so it can't be revoked + rows += ''; + + for ( const share of shares ) { + const holder = html_encode(share.holder ?? ''); + if ( share.inheritedFrom ) { + // Granted on an ancestor, so it can only be changed there + rows += ''; + continue; + } + rows += ''; + } + if ( !shares.length ) { + rows += ``; + } + $list.html(rows); + }; + + const refresh = async () => { + try { + render(await puter.fs.getShares(item_path)); + } catch (e) { + show_error(e?.message ?? i18n('share_failed')); + } + }; + + $(el_window).on('click', '.share-btn', async function () { + const recipient = $(el_window).find('.share-recipient').val().trim(); + if ( !recipient ) return; + + $(this).prop('disabled', true); + try { + await puter.fs.share({ + path: item_path, + recipient, + mode: $(el_window).find('.share-mode').val(), + }); + $(el_window).find('.share-recipient').val(''); + $error.hide(); + show_success(i18n('share_shared_with', { recipient: html_encode(recipient) })); + invalidate_shared_roots(); + await refresh(); + } catch (e) { + show_error(e?.message ?? i18n('share_failed')); + } finally { + $(this).prop('disabled', false); + } + }); + + $(el_window).on('change', '.share-row-mode-select', async function () { + const holder = $(this).attr('data-holder'); + const mode = $(this).val(); + $(this).prop('disabled', true); + try { + await puter.fs.share({ path: item_path, recipient: holder, mode }); + show_success(i18n('share_shared_with', { recipient: html_encode(holder) })); + invalidate_shared_roots(); + await refresh(); + } catch (e) { + show_error(e?.message ?? i18n('share_failed')); + invalidate_shared_roots(); + await refresh(); + } + }); + + $(el_window).on('click', '.share-revoke', async function () { + const holder = $(this).attr('data-holder'); + const confirmed = await UIAlert({ + message: i18n('share_confirm_remove', { recipient: holder }), + buttons: [ + { label: i18n('share_remove'), value: true, type: 'primary' }, + { label: i18n('cancel'), value: false }, + ], + }); + if ( ! confirmed ) return; + $(this).prop('disabled', true); + try { + await puter.fs.unshare(item_path, holder); + show_success(i18n('share_access_removed', { recipient: html_encode(holder) })); + invalidate_shared_roots(); + await refresh(); + } catch (e) { + show_error(e?.message ?? i18n('share_failed')); + $(this).prop('disabled', false); + } + }); + + await refresh(); + return el_window; +} + +export default UIWindowShare; diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 0a3ca51d03..3a1554faf4 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -6537,3 +6537,123 @@ html.dark-mode .usage-table-show-less:hover { background: #1e1e22; } } + +/** + * Share dialog + */ +.share-dialog { + padding: 20px; +} + +.share-dialog-row { + display: flex; + gap: 8px; + margin-bottom: 15px; +} + +.share-dialog .share-recipient { + flex: 1; +} + +.share-dialog .share-mode { + width: 130px; + flex: none; +} + +.share-dialog-heading { + font-size: 13px; + font-weight: 500; + color: #5f6b7a; + margin: 24px 0 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.share-dialog-empty { + font-size: 13px; + color: #7f8b99; + margin: 0; + padding: 6px 0; +} + +.share-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-top: 1px solid #eef1f4; + font-size: 14px; +} + +.share-row:first-child { + border-top: none; +} + +.share-row-who { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.share-row-mode { + color: #7f8b99; + font-size: 12px; + margin-left: auto; + flex: none; +} + +.share-dialog .share-row-mode-select { + width: auto; + min-width: 110px; + margin-left: auto; + flex: none; + padding: 4px 6px; + font-size: 13px; +} + +.share-row-inherited { + color: #7f8b99; +} + +.share-row-via { + font-size: 12px; + color: #9aa5b1; + flex: none; +} + +.share-row-inherited .share-row-mode { + margin-left: 0; +} + +.share-dialog .share-revoke { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + flex: none; + border: none; + border-radius: 4px; + background: none; + color: #6b7785; + cursor: pointer; +} + +.share-dialog .share-revoke:hover:not(:disabled) { + background-color: #eceff2; + color: #d1242f; +} + +.share-dialog .share-revoke:disabled { + opacity: 0.4; + cursor: default; +} + +.share-row-owner { + color: #7f8b99; + font-size: 12px; + flex: none; + padding-right: 4px; +} diff --git a/src/gui/src/globals.js b/src/gui/src/globals.js index c0724d13d9..2c789de3ec 100644 --- a/src/gui/src/globals.js +++ b/src/gui/src/globals.js @@ -93,6 +93,11 @@ if ( window.user !== undefined && window.user !== null ) { } window.root_dirname = 'Puter'; +// Not a real directory — items shared with this user live under their owners' +// paths. Deliberately not path-shaped so it can never collide with a folder +// someone actually creates. +window.shared_path = 'puter://shared'; + // user preferences, persisted across sessions, cached in localStorage try { window.user_preferences = JSON.parse(localStorage.getItem('user_preferences')); diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index fd02387ef4..af15fcb18d 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -20,6 +20,8 @@ import get_html_element_from_options from './helpers/get_html_element_from_options.js'; import globToRegExp from './helpers/globToRegExp.js'; import item_icon from './helpers/item_icon.js'; +import { is_owned_by_me, trash_path_for } from './helpers/path_owner.js'; +import { invalidate_shared_roots } from './helpers/shared_access.js'; import truncate_filename from './helpers/truncate_filename.js'; import update_title_based_on_uploads from './helpers/update_title_based_on_uploads.js'; import update_username_in_gui from './helpers/update_username_in_gui.js'; @@ -691,6 +693,7 @@ window.update_auth_data = async (auth_token, user) => { window.desktop_path = `/${ window.user.username }/Desktop`; window.home_path = `/${ window.user.username}`; window.public_path = `/${ window.user.username }/Public`; + window.shared_path = 'puter://shared'; if ( window.user !== null && !window.user.is_temp ) { $('.user-options-login-btn, .user-options-create-account-btn').hide(); @@ -1719,6 +1722,10 @@ window.refresh_trash_state = async function () { * @returns {Promise} */ window.move_items = async function (el_items, dest_path, is_undo = false) { + // The Shared view is a query, not a directory — nothing can be moved + // into it. Backstop for any drop target the surfaces fail to exclude. + if ( dest_path === window.shared_path ) return; + let move_op_id = window.operation_id++; window.operation_cancelled[move_op_id] = false; @@ -1786,8 +1793,17 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { continue; } + // Deleting sends an item to its owner's trash, not yours. + const is_trashing = dest_path === window.trash_path; + const item_dest_path = is_trashing + ? trash_path_for( + $(el_item).attr('data-path'), + $(el_item).attr('data-owner'), + ) + : dest_path; + // cannot move item to its own path, skip it - if ( path.dirname($(el_item).attr('data-path')) === dest_path ) { + if ( path.dirname($(el_item).attr('data-path')) === item_dest_path ) { // pause the progress-window timer while waiting for the user clearTimeout(progwin_timeout); await UIAlert(`

    Moving ${html_encode($(el_item).attr('data-name'))}

    Cannot move item to its current location.`); @@ -1843,7 +1859,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { // -------------------------------------------------------- // Trashing // -------------------------------------------------------- - if ( dest_path === window.trash_path ) { + if ( is_trashing ) { new_name = $(el_item).attr('data-uid'); metadata = { original_name: $(el_item).attr('data-name'), @@ -1893,7 +1909,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { // execute move let resp = await puter.fs.move({ source: $(el_item).attr('data-uid'), - destination: dest_path, + destination: item_dest_path, overwrite: overwrite || overwrite_all, // "Keep Both" conflict resolution: move under a deduped // "name (1)" style name instead of overwriting @@ -1908,7 +1924,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { let fsentry = resp.moved; // path must use the real name from DB - fsentry.path = path.join(dest_path, fsentry.name); + fsentry.path = path.join(item_dest_path, fsentry.name); // skip next loop iteration because this iteration was successful item_with_same_name_already_exists = false; @@ -1943,7 +1959,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { }); // if trashing, close windows of trashed items and its descendants - if ( dest_path === window.trash_path ) { + if ( is_trashing ) { $(`.window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).close(); // todo this has to be case-insensitive but the `i` selector doesn't work on ^= $(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"]`).close(); @@ -1953,11 +1969,11 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { else { // todo this has to be case-insensitive but the `i` selector doesn't work on ^= $(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"], .window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).each(function () { - window.update_window_path(this, $(this).attr('data-path').replace($(el_item).attr('data-path'), path.join(dest_path, fsentry.name))); + window.update_window_path(this, $(this).attr('data-path').replace($(el_item).attr('data-path'), path.join(item_dest_path, fsentry.name))); }); } - if ( dest_path === window.trash_path ) { + if ( is_trashing ) { // if trashing dir... if ( $(el_item).attr('data-is_dir') === '1' ) { // disassociate all its websites @@ -1981,19 +1997,19 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { // create new item on matching containers const options = { - appendTo: $(`.item-container[data-path="${html_encode(dest_path)}" i]`), + appendTo: $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`), immutable: fsentry.immutable || (fsentry.writable === false), associated_app_name: fsentry.associated_app?.name, uid: fsentry.uid, path: fsentry.path, icon: await item_icon(fsentry), - name: (dest_path === window.trash_path) ? $(el_item).attr('data-name') : fsentry.name, + name: is_trashing ? $(el_item).attr('data-name') : fsentry.name, is_dir: fsentry.is_dir, size: fsentry.size, type: fsentry.type, modified: fsentry.modified, is_selected: false, - is_shared: (dest_path === window.trash_path) ? false : fsentry.is_shared, + is_shared: is_trashing ? false : fsentry.is_shared, is_shortcut: fsentry.is_shortcut, shortcut_to: fsentry.shortcut_to, shortcut_to_path: fsentry.shortcut_to_path, @@ -2039,7 +2055,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { }); //sort each container - $(`.item-container[data-path="${html_encode(dest_path)}" i]`).each(function () { + $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`).each(function () { window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order')); }); } catch ( err ) { @@ -3177,6 +3193,10 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i new_name: new_name, excludeSocketID: window.socket?.id, success: async (fsentry) => { + // A renamed shared item is cached under its old path — drop the + // cache so mode lookups against the new path don't miss. + if ( ! is_owned_by_me(old_path) ) invalidate_shared_roots(); + // Add action to actions_history for undo ability if ( ! is_undo ) { diff --git a/src/gui/src/helpers/actionIcons.js b/src/gui/src/helpers/actionIcons.js new file mode 100644 index 0000000000..2f8e8b4bce --- /dev/null +++ b/src/gui/src/helpers/actionIcons.js @@ -0,0 +1,43 @@ +/* + * 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 . + */ + +// Inline action glyphs shared by the file UI. + +export const icons = { + document: ``, + files: ``, + folder: ``, + more: ``, + // Header action icons use the Material Symbols wght300 cut (one step + // lighter than the default 400) to match the thinned nav arrows. + newFolder: ``, + upload: ``, + trash: ``, + download: ``, + cut: ``, + copy: ``, + restore: ``, + list: ``, + grid: ``, + gridSmall: ``, + sort: ``, + select: ``, + done: ``, + worker: ``, +}; diff --git a/src/gui/src/helpers/generate_file_context_menu.js b/src/gui/src/helpers/generate_file_context_menu.js index a98b9f249b..19edbb8890 100644 --- a/src/gui/src/helpers/generate_file_context_menu.js +++ b/src/gui/src/helpers/generate_file_context_menu.js @@ -23,11 +23,14 @@ import UIWindowItemProperties from '../UI/UIWindowItemProperties.js'; import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js'; import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js'; import UIWindowPublishWorker from '../UI/UIWindowPublishWorker.js'; +import UIWindowShare from '../UI/UIWindowShare.js'; import publish_as_website from './publish_as_website.js'; import open_item from './open_item.js'; import launch_app from './launch_app.js'; import path from '../lib/path.js'; import { isWeblinkName, weblinkChangeIconMenuItem } from './weblink.js'; +import { is_owned_by_me } from './path_owner.js'; +import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from './shared_access.js'; /** * Generates context menu items for file/folder operations @@ -50,6 +53,27 @@ const generate_file_context_menu = async function (options) { const fsentry = options.fsentry || {}; const is_trash = options.is_trash ?? false; const is_trashed = options.is_trashed ?? false; + // Has its own share, so it is a row the Shared view listed and can be left. + const is_shared_root = $(options.element).attr('data-shared_with_me') === '1'; + // Someone else's, however we got here — including items reached by opening + // a shared folder, which carry no share markers of their own. + const is_not_mine = !is_owned_by_me($(options.element).attr('data-path')); + // `manage` inherits downwards, so a file inside a folder you manage + // counts too — the row itself only carries a mode at a shared root. + const can_manage_share = + $(options.element).attr('data-share_mode') === 'manage' + || (await shared_mode_for($(options.element).attr('data-path'))) === 'manage'; + // Moving and deleting go by the holding folder, not by the item. + const may_restructure = !is_not_mine + || await can_restructure($(options.element).attr('data-path')); + // A shared FILE you hold write on is renameable even though it can't be + // moved; a shared folder root is not. + const may_rename = !is_not_mine + || await can_rename( + $(options.element).attr('data-path'), + fsentry.is_dir === true + || ['1', 'true'].includes($(options.element).attr('data-is_dir')), + ); const is_worker = options.is_worker ?? false; const onOpen = options.onOpen; const is_weblink = isWeblinkName(fsentry.name ?? $(el_item).attr('data-name')); @@ -292,10 +316,50 @@ const generate_file_context_menu = async function (options) { menu_items.push(weblinkChangeIconMenuItem(el_item)); } + // ------------------------------------------- + // Share + // ------------------------------------------- + if ( !is_trash && !is_trashed && (!is_not_mine || can_manage_share) ) { + menu_items.push({ + html: i18n('share_ellipsis'), + onClick: async function () { + UIWindowShare({ + path: $(el_item).attr('data-path'), + name: $(el_item).attr('data-name'), + }); + }, + }); + } + + // ------------------------------------------- + // Remove from Shared + // ------------------------------------------- + // Can't trash someone else's file, so give up our own access instead. Only + // for an item shared directly — access to a child is held on the folder. + if ( is_shared_root ) { + menu_items.push({ + html: i18n('share_remove_from_shared'), + onClick: async function () { + try { + await puter.fs.unshare( + $(el_item).attr('data-path'), + window.user.username, + ); + // Or mode lookups keep answering for a share we just + // walked away from. + invalidate_shared_roots(); + $(el_item).remove(); + } catch (e) { + UIAlert({ message: e?.message ?? i18n('error_unknown_cause') }); + } + }, + }); + } + // ------------------------------------------- // Delete // ------------------------------------------- - if ( $(el_item).attr('data-immutable') === '0' && !is_trashed ) { + if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) { menu_items.push({ html: i18n('delete'), onClick: async function () { @@ -335,7 +399,7 @@ const generate_file_context_menu = async function (options) { // ------------------------------------------- // Rename // ------------------------------------------- - if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash ) { + if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && !is_trash && may_rename ) { menu_items.push({ html: i18n('rename'), onClick: function () { diff --git a/src/gui/src/helpers/list_all_shared.js b/src/gui/src/helpers/list_all_shared.js new file mode 100644 index 0000000000..b9954cff75 --- /dev/null +++ b/src/gui/src/helpers/list_all_shared.js @@ -0,0 +1,46 @@ +/* + * 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 . + */ + +// Largest page the backend will serve (ShareStore.MAX_HOLDER_PAGE_SIZE). +const PAGE_SIZE = 200; + +/** + * Every share the current user holds, across all pages. A page can be short + * once unreachable items are filtered out, so it pages on `cursor` rather than + * on the item count. + * + * @returns {Promise>} + */ +const list_all_shared = async () => { + const shares = []; + let cursor; + + do { + const page = await window.puter.fs.listShared({ + limit: PAGE_SIZE, + ...(cursor ? { cursor } : {}), + }); + shares.push(...(page.items ?? [])); + cursor = page.cursor; + } while ( cursor ); + + return shares; +}; + +export default list_all_shared; diff --git a/src/gui/src/helpers/path_owner.js b/src/gui/src/helpers/path_owner.js new file mode 100644 index 0000000000..1368f3a803 --- /dev/null +++ b/src/gui/src/helpers/path_owner.js @@ -0,0 +1,55 @@ +/* + * 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 . + */ + +/** + * Owner of an item, read off its path — Puter paths are `/{username}/…`. + * + * @param {string} path + * @returns {string|null} null when the path names no owner (relative, or `/`). + */ +export const owner_of_path = (path) => + typeof path === 'string' ? path.split('/').filter(Boolean)[0] ?? null : null; + +/** + * Whether the signed-in user owns the item at `path`. + * + * Works however the item was reached, which the `data-shared_with_me` marker + * does not: that is only set on rows the Shared view itself listed, so an item + * opened *inside* a shared folder arrives looking like one of your own. + * Unknown ownership counts as yours, leaving ordinary paths untouched. + * + * @param {string} path + * @returns {boolean} + */ +export const is_owned_by_me = (path) => { + const owner = owner_of_path(path); + return owner === null || owner === window.user?.username; +}; + +/** + * Trash an item belongs in — its owner's, not yours. + * + * @param {string} path + * @param {string} [owner] username from the entry, when known + * @returns {string} + */ +export const trash_path_for = (path, owner) => { + const from_path = path?.startsWith('~') ? null : owner_of_path(path); + return `/${owner || from_path || window.user?.username}/Trash`; +}; diff --git a/src/gui/src/helpers/refresh_item_container.js b/src/gui/src/helpers/refresh_item_container.js index 51956aef80..e3c6fff413 100644 --- a/src/gui/src/helpers/refresh_item_container.js +++ b/src/gui/src/helpers/refresh_item_container.js @@ -20,6 +20,8 @@ import path from '../lib/path.js'; import UIItem from '../UI/UIItem.js'; import item_icon from './item_icon.js'; +import list_all_shared from './list_all_shared.js'; +import { remember_shared_roots } from './shared_access.js'; const refresh_item_container = function (el_item_container, options) { // start a transaction @@ -73,7 +75,19 @@ const refresh_item_container = function (el_item_container, options) { // -------------------------------------------------------- // Folder's configs and properties // -------------------------------------------------------- - puter.fs.stat({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(fsentry => { + // The Shared view is a query, not a directory — there is no fsentry to + // stat, and its entries live under their owners' paths. + const is_shared_view = container_path === window.shared_path; + + if ( is_shared_view && el_window ) { + $(el_window).attr('data-uid', 'null'); + $(el_window).find('.window-head-title').text(i18n('shared_with_me')); + if ( el_window_head_icon ) { + $(el_window_head_icon).attr('src', window.icons['shared.svg']); + } + } + + if ( !is_shared_view ) puter.fs.stat({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(fsentry => { if ( el_window ) { $(el_window).attr('data-uid', fsentry.id); $(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name'); @@ -114,7 +128,32 @@ const refresh_item_container = function (el_item_container, options) { $(el_item_container).find('.item').removeItems(); // get items with subdomains/workers included to avoid per-item stat calls - puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' }).then(async (fsentries) => { + const entries_promise = is_shared_view + ? list_all_shared().then((shares) => { + remember_shared_roots(shares); + return shares; + }).then((shares) => shares.map((share) => ({ + uid: share.entryUid, + // Share paths are masked (`/owner/uuid/name`), so prefer the name + // the share row carries over parsing it off the path. + name: share.name ?? path.basename(share.path), + path: share.path, + is_dir: share.isDir, + type: share.type, + thumbnail: share.thumbnail, + modified: share.modified, + size: share.size, + // Carried so the context menu can offer "remove from shared" + // rather than a delete the backend would refuse. + shared_with_me: true, + share_mode: share.mode, + shared_by: share.issuer, + owner: share.owner, + metadata: '', + }))) + : puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' }); + + entries_promise.then(async (fsentries) => { // Check if the same folder is still loading since el_item_container's // data-path might have changed by other operations while waiting for the response to this `readdir`. if ( $(el_item_container).attr('data-path') !== container_path ) @@ -212,6 +251,10 @@ const refresh_item_container = function (el_item_container, options) { disabled: is_disabled, visible: visible, position: position, + shared_with_me: fsentry.shared_with_me, + share_mode: fsentry.share_mode, + shared_by: fsentry.shared_by, + owner: fsentry.owner?.username ?? fsentry.owner, }); } } diff --git a/src/gui/src/helpers/share_paths.js b/src/gui/src/helpers/share_paths.js new file mode 100644 index 0000000000..1d056f634b --- /dev/null +++ b/src/gui/src/helpers/share_paths.js @@ -0,0 +1,85 @@ +/* + * 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 . + */ + +/** + * Items other people share with you arrive as `/{owner}/{uid}/{name}[/…]`. + * The `{uid}` segment stands in for wherever the owner keeps the item, so the + * path is addressable without saying anything about their folders. + * + * Everything here reads that shape directly. Nothing needs the share listing, + * so it all works on a deep link or a restored window. + */ + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * @typedef {{owner: string, uid: string, segments: string[]}} SharedPathParts + */ + +/** + * @param {string} abs_path + * @returns {SharedPathParts|null} null when the path is not a shared one + */ +export const parse_shared_path = (abs_path) => { + if ( typeof abs_path !== 'string' || ! abs_path.startsWith('/') ) return null; + const [owner, uid, ...segments] = abs_path.slice(1).split('/'); + if ( ! owner || ! uid || ! UUID.test(uid) ) return null; + if ( segments.length === 0 ) return null; + return { owner, uid, segments }; +}; + +/** The shared item itself, as opposed to something inside it. */ +export const is_share_root = (abs_path) => + parse_shared_path(abs_path)?.segments.length === 1; + +/** + * Where the Up button goes. Above a shared item there is only the owner's own + * folder, which is not yours to open — so the Shared view stands in for it. + * + * @param {string} abs_path + * @returns {string} + */ +export const parent_path_for = (abs_path) => { + if ( abs_path === window.shared_path ) return abs_path; + if ( is_share_root(abs_path) ) return window.shared_path; + const parent = abs_path.slice(0, abs_path.lastIndexOf('/')); + return parent === '' ? '/' : parent; +}; + +/** + * @typedef {{label: string, path: string}} PathCrumb + */ + +/** + * What the directory bar shows for a path. Only the label changes — every + * segment keeps the real path it navigates to. + * + * @param {string} abs_path + * @returns {PathCrumb[]|null} null when the path is the viewer's own + */ +export const shared_crumbs_for = (abs_path) => { + const parts = parse_shared_path(abs_path); + if ( ! parts || parts.owner === window.user?.username ) return null; + + let cursor = `/${parts.owner}/${parts.uid}`; + return parts.segments.map((segment) => { + cursor += `/${segment}`; + return { label: segment, path: cursor }; + }); +}; diff --git a/src/gui/src/helpers/share_paths.test.js b/src/gui/src/helpers/share_paths.test.js new file mode 100644 index 0000000000..53550af580 --- /dev/null +++ b/src/gui/src/helpers/share_paths.test.js @@ -0,0 +1,115 @@ +/* + * 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 . + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + is_share_root, + parent_path_for, + parse_shared_path, + shared_crumbs_for, +} from './share_paths.js'; + +const UID = '11111111-2222-3333-4444-555555555555'; + +beforeEach(() => { + globalThis.window = { + user: { username: 'sharemate' }, + shared_path: 'puter://shared', + }; +}); + +describe('parse_shared_path', () => { + it('reads owner, uid and the segments below it', () => { + expect(parse_shared_path(`/jfcastro/${UID}/Contents/sub/f.txt`)).toEqual( + { owner: 'jfcastro', uid: UID, segments: ['Contents', 'sub', 'f.txt'] }, + ); + }); + + it('is not fooled by an ordinary path', () => { + expect(parse_shared_path('/jfcastro/Documents/f.txt')).toBeNull(); + expect(parse_shared_path(`/jfcastro/${UID}`)).toBeNull(); + expect(parse_shared_path('relative')).toBeNull(); + }); +}); + +describe('is_share_root', () => { + it('is true only for the shared item itself', () => { + expect(is_share_root(`/jfcastro/${UID}/Contents`)).toBe(true); + expect(is_share_root(`/jfcastro/${UID}/Contents/sub`)).toBe(false); + expect(is_share_root('/sharemate/Documents')).toBe(false); + }); +}); + +describe('parent_path_for', () => { + it('sends the shared item up to Shared, not into the owner’s folder', () => { + expect(parent_path_for(`/jfcastro/${UID}/Contents`)).toBe( + 'puter://shared', + ); + }); + + it('walks normally inside the shared item', () => { + expect(parent_path_for(`/jfcastro/${UID}/Contents/sub`)).toBe( + `/jfcastro/${UID}/Contents`, + ); + }); + + it('stops at Shared', () => { + expect(parent_path_for('puter://shared')).toBe('puter://shared'); + }); + + it('leaves ordinary paths to ordinary rules', () => { + expect(parent_path_for('/sharemate/Documents/a.txt')).toBe( + '/sharemate/Documents', + ); + expect(parent_path_for('/sharemate')).toBe('/'); + }); +}); + +describe('shared_crumbs_for', () => { + it('leaves the viewer’s own paths unmasked', () => { + expect(shared_crumbs_for('/sharemate/Documents/a.txt')).toBeNull(); + }); + + it('shows a shared item by its own name', () => { + expect(shared_crumbs_for(`/jfcastro/${UID}/_CodeSignature`)).toEqual([ + { label: '_CodeSignature', path: `/jfcastro/${UID}/_CodeSignature` }, + ]); + }); + + it('keeps the addressable path on every crumb below it', () => { + expect( + shared_crumbs_for(`/jfcastro/${UID}/Contents/sub/CodeResources`), + ).toEqual([ + { label: 'Contents', path: `/jfcastro/${UID}/Contents` }, + { label: 'sub', path: `/jfcastro/${UID}/Contents/sub` }, + { + label: 'CodeResources', + path: `/jfcastro/${UID}/Contents/sub/CodeResources`, + }, + ]); + }); + + it('never names the owner’s folders above the share', () => { + const labels = shared_crumbs_for( + `/jfcastro/${UID}/Contents/deep/f.txt`, + ).map((c) => c.label); + expect(labels).toEqual(['Contents', 'deep', 'f.txt']); + expect(labels).not.toContain('Documents'); + }); +}); diff --git a/src/gui/src/helpers/shared_access.js b/src/gui/src/helpers/shared_access.js new file mode 100644 index 0000000000..f6e238d9c2 --- /dev/null +++ b/src/gui/src/helpers/shared_access.js @@ -0,0 +1,142 @@ +/* + * 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 . + */ + +import list_all_shared from './list_all_shared.js'; +import { is_owned_by_me } from './path_owner.js'; +import { is_share_root } from './share_paths.js'; + +// What we hold on each shared root, by path: `{ mode, name }`. A `readdir` +// inside a shared folder returns plain entries, so nothing else records it. +const roots = new Map(); +let loaded = false; +let inflight = null; + +/** + * Record what a Shared listing reported. Replaces rather than merges, so a + * share withdrawn elsewhere does not linger. + */ +export const remember_shared_roots = (shares) => { + roots.clear(); + for ( const share of shares ) { + if ( ! share?.path || ! share?.mode ) continue; + roots.set(share.path, { mode: share.mode, name: share.name }); + } + loaded = true; +}; + +/** Drop what we know; the next lookup re-reads it. */ +export const invalidate_shared_roots = () => { + roots.clear(); + loaded = false; + inflight = null; +}; + +// A deep link or restored window never ran the Shared listing, so fetch on +// first use. One request per miss; concurrent callers share it. +const load_once = () => { + if ( loaded ) return Promise.resolve(); + inflight ??= list_all_shared() + .then(remember_shared_roots) + .catch(() => { + // Retry next time; a miss only hides an action, so never block. + }) + .finally(() => { + inflight = null; + }); + return inflight; +}; + +/** + * Mode held on `path` or on the nearest shared ancestor of it. + * + * @param {string} path + * @returns {Promise} + */ +export const shared_mode_for = async (path) => { + if ( typeof path !== 'string' || path === '' ) return null; + await load_once(); + return shared_root_for(path)?.mode ?? null; +}; + +/** + * Whether anything is shared with the user at all. + * + * @returns {Promise} + */ +export const has_shared_roots = async () => { + await load_once(); + return roots.size > 0; +}; + +/** + * The shared root `path` sits in, from what is already loaded. + * + * @param {string} path + * @returns {{path: string, mode: string, name: string|undefined}|null} + */ +export const shared_root_for = (path) => { + if ( typeof path !== 'string' || path === '' ) return null; + let best = null; + for ( const root of roots.keys() ) { + if ( path !== root && ! path.startsWith(`${root}/`) ) continue; + if ( best === null || root.length > best.length ) best = root; + } + return best === null ? null : { path: best, ...roots.get(best) }; +}; + +/** + * May you rename the item at `item_path`? + * + * A FILE shared directly with you renames with `write` on it — the name is + * the file's own. A folder's name is structure the owner's subtree hangs + * off, so a shared folder root stays fixed; everything reached inside a + * shared folder goes by the holding folder, exactly like moving or deleting. + * The backend authorizes rename the same way. + * + * @param {string} item_path + * @param {boolean} [is_dir] + * @returns {Promise} + */ +export const can_rename = async (item_path, is_dir = false) => { + if ( typeof item_path !== 'string' ) return false; + if ( is_owned_by_me(item_path) ) return true; + if ( is_share_root(item_path) ) { + if ( is_dir ) return false; + return ['write', 'manage'].includes(await shared_mode_for(item_path)); + } + return can_restructure(item_path); +}; + +/** + * May you move or delete the item at `item_path`? + * + * The folder holding it decides, which is what the backend enforces too. A + * shared item is therefore fixed — its folder belongs to its owner — while + * anything inside a folder you can write to is yours to reorganize. + * + * @param {string} item_path + * @returns {Promise} + */ +export const can_restructure = async (item_path) => { + if ( typeof item_path !== 'string' ) return false; + if ( is_owned_by_me(item_path) ) return true; + if ( is_share_root(item_path) ) return false; + const parent = item_path.slice(0, item_path.lastIndexOf('/')); + return ['write', 'manage'].includes(await shared_mode_for(parent)); +}; diff --git a/src/gui/src/helpers/shared_access.test.js b/src/gui/src/helpers/shared_access.test.js new file mode 100644 index 0000000000..e767e246c7 --- /dev/null +++ b/src/gui/src/helpers/shared_access.test.js @@ -0,0 +1,153 @@ +/* + * 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 . + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + can_rename, + can_restructure, + invalidate_shared_roots, + remember_shared_roots, + shared_mode_for, +} from './shared_access.js'; + +const CONTENTS = '11111111-1111-1111-1111-111111111111'; +const PHOTOS = '22222222-2222-2222-2222-222222222222'; +const BUDGET = '33333333-3333-3333-3333-333333333333'; +const REPORT = '44444444-4444-4444-4444-444444444444'; + +describe('shared_access', () => { + beforeEach(() => { + invalidate_shared_roots(); + globalThis.window = { user: { username: 'sharemate' } }; + // Shared roots arrive masked: `/{owner}/{uid}/{name}`. + remember_shared_roots([ + { path: `/jf/${CONTENTS}/Contents`, mode: 'write' }, + { path: `/jf/${PHOTOS}/Photos`, mode: 'read' }, + { path: `/jf/${BUDGET}/Budget`, mode: 'manage' }, + { path: `/jf/${REPORT}/report.pdf`, mode: 'write' }, + ]); + }); + + describe('shared_mode_for', () => { + it('reports the mode held on a shared root', async () => { + expect(await shared_mode_for(`/jf/${PHOTOS}/Photos`)).toBe('read'); + }); + + it('inherits the mode down into the folder', async () => { + expect(await shared_mode_for(`/jf/${PHOTOS}/Photos/2024/a.jpg`)).toBe('read'); + }); + + it('prefers the nearest shared ancestor', async () => { + remember_shared_roots([ + { path: '/jf/Documents', mode: 'read' }, + { path: `/jf/${CONTENTS}/Contents`, mode: 'write' }, + ]); + expect(await shared_mode_for(`/jf/${CONTENTS}/Contents/a.txt`)).toBe( + 'write', + ); + }); + + it('reports nothing outside every shared root', async () => { + expect(await shared_mode_for(`/jf/${CONTENTS}/Private/a.txt`)).toBe(null); + }); + }); + + describe('can_restructure', () => { + it('allows an item inside a folder shared for writing', async () => { + expect( + await can_restructure(`/jf/${CONTENTS}/Contents/a.txt`), + ).toBe(true); + }); + + it('allows an item nested deeper in that folder', async () => { + expect( + await can_restructure(`/jf/${CONTENTS}/Contents/sub/a.txt`), + ).toBe(true); + }); + + it('allows an item inside a folder shared for managing', async () => { + expect(await can_restructure(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true); + }); + + it('refuses the shared folder itself', async () => { + expect(await can_restructure(`/jf/${CONTENTS}/Contents`)).toBe(false); + }); + + it('refuses a file shared directly', async () => { + expect(await can_restructure(`/jf/${REPORT}/report.pdf`)).toBe(false); + }); + + it('refuses inside a folder shared read-only', async () => { + expect(await can_restructure(`/jf/${PHOTOS}/Photos/a.jpg`)).toBe(false); + }); + + it('refuses a path that is not shared at all', async () => { + expect(await can_restructure(`/jf/${CONTENTS}/Private/a.txt`)).toBe(false); + }); + + it('refuses a non-string path', async () => { + expect(await can_restructure(undefined)).toBe(false); + }); + + it('allows your own items, shared or not', async () => { + expect(await can_restructure('/sharemate/Documents/a.txt')).toBe( + true, + ); + }); + }); + + describe('can_rename', () => { + it('allows a file shared directly for writing', async () => { + expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true); + }); + + it('refuses a shared folder root, even with write', async () => { + expect(await can_rename(`/jf/${CONTENTS}/Contents`, true)).toBe(false); + }); + + it('refuses a shared folder root held with manage', async () => { + expect(await can_rename(`/jf/${BUDGET}/Budget`, true)).toBe(false); + }); + + it('allows items inside a folder shared for writing', async () => { + expect(await can_rename(`/jf/${CONTENTS}/Contents/a.txt`)).toBe(true); + expect( + await can_rename(`/jf/${CONTENTS}/Contents/sub`, true), + ).toBe(true); + }); + + it('refuses anything shared read-only', async () => { + expect(await can_rename(`/jf/${PHOTOS}/Photos`, true)).toBe(false); + expect(await can_rename(`/jf/${PHOTOS}/Photos/a.jpg`)).toBe(false); + }); + + it('refuses a path that is not shared at all', async () => { + expect(await can_rename(`/jf/${CONTENTS}/Private/a.txt`)).toBe(false); + }); + + it('refuses a non-string path', async () => { + expect(await can_rename(undefined)).toBe(false); + }); + + it('allows your own items', async () => { + expect(await can_rename('/sharemate/Documents/a.txt')).toBe(true); + expect(await can_rename('/sharemate/Documents', true)).toBe(true); + }); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 3b318fcb46..017787addd 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -372,7 +372,29 @@ const en = { keyboard_shortcuts_permanent_delete: 'Permanently delete (after confirmation)', set_new_password: 'Set New Password', share: 'Share', + share_ellipsis: 'Share…', share_to: 'Share to', + shared: 'Shared', + shared_with_me: 'Shared with me', + shared_by: 'Shared by', + share_access_read: 'Can view', + share_access_write: 'Can edit', + share_access_manage: 'Can edit & share', + share_add_people: 'Add people by email or username', + share_who_has_access: 'Who has access', + share_no_one: 'Not shared with anyone yet.', + share_owner: 'Owner', + share_remove_access: 'Remove access', + share_remove_from_shared: 'Remove from Shared', + share_nothing_shared: 'Nothing has been shared with you yet.', + share_done: 'Done', + share_failed: 'Could not share this item.', + share_shared_with: 'Shared with {{recipient}}', + share_access_removed: 'Removed {{recipient}}', + share_confirm_remove: 'Remove {{recipient}}’s access to this item?', + share_remove: 'Remove', + share_you: 'you', + share_inherited_via: 'via {{folder}}', share_with: 'Share with:', shortcut_to: 'Shortcut to', show_all_windows: 'Show All Windows', diff --git a/src/gui/src/icons/folder-shared.svg b/src/gui/src/icons/folder-shared.svg new file mode 100644 index 0000000000..3934678470 --- /dev/null +++ b/src/gui/src/icons/folder-shared.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/gui/src/icons/sidebar-folder-shared.svg b/src/gui/src/icons/sidebar-folder-shared.svg new file mode 100644 index 0000000000..3934678470 --- /dev/null +++ b/src/gui/src/icons/sidebar-folder-shared.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/gui/src/index.js b/src/gui/src/index.js index f39473dbf7..f0f4a5cfdd 100644 --- a/src/gui/src/index.js +++ b/src/gui/src/index.js @@ -75,7 +75,7 @@ window.gui = async (options) => { else if ( window.gui_env === 'prod' ) { // This stuff is now handled in the backend in PuterHomepageService - await window.loadScript('https://js.puter.com/v2/'); + await window.loadScript(options.puterjs_bundle ?? 'https://js.puter.com/v2/'); // Load the minified bundles // await window.loadCSS('/dist/bundle.min.css'); } diff --git a/src/gui/src/keyboard.js b/src/gui/src/keyboard.js index 3523bf15e0..5ce7665e6b 100644 --- a/src/gui/src/keyboard.js +++ b/src/gui/src/keyboard.js @@ -884,6 +884,11 @@ $(document).bind('keyup keydown', async function (e) { { return; } + // ... or into the Shared view — a query, not a directory + if ( target_path === window.shared_path ) + { + return; + } // execute clipboard operation if ( window.clipboard_op === 'copy' ) { diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 504e7726e8..3cee3cafae 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -96,14 +96,22 @@ export type { export type { CopyOptions, DeleteOptions, + GetSharesOptions, + ListSharedOptions, MkdirOptions, MoveOptions, ReadOptions, ReaddirOptions, RenameOptions, + Share, + ShareMode, + ShareOptions, + SharePage, + ShareRecipient, SignResult, SpaceInfo, StatOptions, + UnshareOptions, UploadBatchError, UploadItems, UploadOperationResult, diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index 4597815929..278e551f68 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -13,6 +13,8 @@ import FSItem from '../FSItem.js'; import copy from './operations/copy.js'; import deleteFSEntry from './operations/deleteFSEntry.js'; import getReadURL from './operations/getReadUrl.js'; +import getShares from './operations/getShares.js'; +import listShared from './operations/listShared.js'; import mkdir from './operations/mkdir.js'; import move from './operations/move.js'; import read from './operations/read.js'; @@ -20,9 +22,11 @@ import readdir from './operations/readdir.js'; import readdirSubdomains from './operations/readdirSubdomains.js'; import rename from './operations/rename.js'; import revokeReadURL from './operations/revokeReadUrl.js'; +import share from './operations/share.js'; import sign from './operations/sign.js'; import space from './operations/space.js'; import stat from './operations/stat.js'; +import unshare from './operations/unshare.js'; import upload from './operations/upload/index.js'; import write from './operations/write.js'; @@ -55,6 +59,12 @@ export class PuterJSFileSystemModule extends PuterModule { readdirSubdomains = readdirSubdomains; stat = stat; + // Sharing + share = share; + unshare = unshare; + listShared = listShared; + getShares = getShares; + FSItem = FSItem; /** diff --git a/src/puter-js/src/modules/FileSystem/operations/getShares.js b/src/puter-js/src/modules/FileSystem/operations/getShares.js new file mode 100644 index 0000000000..7f72cdc929 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/getShares.js @@ -0,0 +1,42 @@ +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { defineOperation } from './scaffold.js'; +import { toShare } from './shareUtil.js'; + +/** @typedef {import('../types.js').GetSharesOptions} GetSharesOptions */ +/** @typedef {import('../types.js').Share} Share */ + +/** + * Lists who can reach a file or directory you can manage. + * + * Includes shares granted by anyone holding `manage` on the item, not only + * your own — which is how an owner sees what a delegate has re-shared. + * + * @type {{ + * (options: GetSharesOptions): Promise, + * ( + * path: string, + * success?: (value: Share[]) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * }} + */ +const getShares = defineOperation({ + positional: ['path'], + request (options) { + const query = new URLSearchParams(); + if ( options.uid !== undefined ) { + query.set('uid', String(options.uid)); + } else { + query.set('path', getAbsolutePathForApp(String(options.path))); + } + + return { + endpoint: `/share/shares?${query.toString()}`, + method: 'get', + transform: (/** @type {{ items?: Record[] }} */ response) => + (response.items ?? []).map(toShare), + }; + }, +}); + +export default getShares; diff --git a/src/puter-js/src/modules/FileSystem/operations/listShared.js b/src/puter-js/src/modules/FileSystem/operations/listShared.js new file mode 100644 index 0000000000..51bf2d5f32 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/listShared.js @@ -0,0 +1,44 @@ +import { defineOperation, firstDefined } from './scaffold.js'; +import { toShare } from './shareUtil.js'; + +/** @typedef {import('../types.js').ListSharedOptions} ListSharedOptions */ +/** @typedef {import('../types.js').SharePage} SharePage */ + +/** + * Lists what other users have shared with you, a page at a time. + * + * `cursor` comes back only while more pages remain, so iterate until it is + * absent rather than comparing `items.length` to `limit` — a page can be short + * once items the caller can no longer see are filtered out. + * + * @type {{ + * (options?: ListSharedOptions): Promise, + * ( + * success?: (value: SharePage) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * }} + */ +const listShared = defineOperation({ + request (options) { + const query = new URLSearchParams(); + if ( options.limit !== undefined ) query.set('limit', String(options.limit)); + if ( options.cursor !== undefined ) query.set('cursor', String(options.cursor)); + if ( firstDefined(options, 'includeTotal', 'include_total') ) { + query.set('includeTotal', 'true'); + } + const suffix = query.toString(); + + return { + endpoint: `/share/shared-with-me${suffix ? `?${suffix}` : ''}`, + method: 'get', + transform: (/** @type {{ items?: Record[], cursor?: string, total?: number }} */ response) => ({ + items: (response.items ?? []).map(toShare), + ...(response.cursor === undefined ? {} : { cursor: response.cursor }), + ...(response.total === undefined ? {} : { total: response.total }), + }), + }; + }, +}); + +export default listShared; diff --git a/src/puter-js/src/modules/FileSystem/operations/share.js b/src/puter-js/src/modules/FileSystem/operations/share.js new file mode 100644 index 0000000000..ea3b42f1a6 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/share.js @@ -0,0 +1,60 @@ +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { defineOperation, firstDefined } from './scaffold.js'; +import { toShare, toShareItems, toShareRecipients } from './shareUtil.js'; + +/** @typedef {import('../types.js').ShareOptions} ShareOptions */ +/** @typedef {import('../types.js').ShareMode} ShareMode */ +/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */ +/** @typedef {import('../types.js').Share} Share */ + +/** + * Gives another Puter user access to a file or directory. Relative paths + * resolve against the app's root directory. + * + * Resolves with one {@link Share} per recipient/item pair that succeeded. A + * pair that fails — an unknown recipient, say — does not fail the others; its + * error is reported on the rejected pair only when every pair failed. + * + * @type {{ + * (options: ShareOptions): Promise, + * ( + * path: string, + * recipient: ShareRecipient | ShareRecipient[], + * mode?: ShareMode, + * success?: (value: Share[]) => void, + * error?: (reason: unknown) => void, + * ): Promise, + * }} + */ +const share = defineOperation({ + positional: ['path', 'recipient', 'mode'], + request (options) { + const recipients = toShareRecipients( + firstDefined(options, 'recipient', 'recipients'), + ); + const items = toShareItems(options, (path) => getAbsolutePathForApp(path)); + + return { + endpoint: '/share', + body: { + recipients, + items, + mode: options.mode ?? 'read', + }, + transform: (/** @type {{ status: string, results: Record[] }} */ response) => { + const results = response.results ?? []; + const ok = results.filter((r) => r.status === 'success'); + if ( ok.length === 0 && results.length > 0 ) { + const first = results[0]; + throw { + message: String(first.message ?? 'Share failed'), + code: String(first.code ?? 'share_failed'), + }; + } + return ok.map(toShare); + }, + }; + }, +}); + +export default share; diff --git a/src/puter-js/src/modules/FileSystem/operations/shareUtil.js b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js new file mode 100644 index 0000000000..fad25ad809 --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js @@ -0,0 +1,78 @@ +// Shared helpers for the sharing operations. + +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; + +/** @typedef {import('../types.js').Share} Share */ +/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */ + +/** + * Normalizes recipients into the wire form. A bare string is read as an email + * when it contains `@`, and as a username otherwise. + * + * @param {unknown} value + * @returns {Array<{ email?: string, username?: string }>} + */ +export const toShareRecipients = (value) => { + const list = Array.isArray(value) ? value : [value]; + return list + .filter((entry) => entry !== undefined && entry !== null) + .map((entry) => { + if ( typeof entry === 'string' ) { + const trimmed = entry.trim(); + return trimmed.includes('@') + ? { email: trimmed } + : { username: trimmed }; + } + const record = /** @type {Record} */ (entry); + return { + ...(record.email ? { email: String(record.email) } : {}), + ...(record.username ? { username: String(record.username) } : {}), + }; + }); +}; + +/** + * Collects whichever of `path`, `paths` or `uid` the caller supplied into the + * wire form. Paths are made absolute; UIDs are passed through. + * + * @param {Record} options + * @param {(path: string) => string} [resolvePath] + * @returns {Array<{ path?: string, uid?: string }>} + */ +export const toShareItems = (options, resolvePath = getAbsolutePathForApp) => { + if ( options.uid !== undefined ) { + const uids = Array.isArray(options.uid) ? options.uid : [options.uid]; + return uids.map((uid) => ({ uid: String(uid) })); + } + const raw = options.paths !== undefined ? options.paths : options.path; + const paths = Array.isArray(raw) ? raw : [raw]; + return paths + .filter((path) => path !== undefined && path !== null) + .map((path) => ({ path: resolvePath(String(path)) })); +}; + +/** + * Turns one wire share into the shape the SDK publishes. + * + * @param {Record} row + * @returns {Share} + */ +export const toShare = (row) => ({ + uid: /** @type {string} */ (row.uid), + mode: /** @type {Share['mode']} */ (row.mode), + path: /** @type {string} */ (row.path), + entryUid: /** @type {string} */ (row.uid_entry ?? row.entryUid), + isDir: Boolean(row.is_dir ?? row.isDir), + // A share listing has no fsentry behind it to stat, so the row carries + // what a file browser needs to render the item. Absent elsewhere. + name: /** @type {string | null} */ (row.name ?? null), + type: /** @type {string | null} */ (row.type ?? null), + thumbnail: /** @type {string | null} */ (row.thumbnail ?? null), + owner: /** @type {string | null} */ (row.owner ?? null), + issuer: /** @type {string | null} */ (row.issuer ?? null), + holder: /** @type {string | null} */ (row.holder ?? null), + inheritedFrom: /** @type {string | null} */ (row.inherited_from ?? null), + issuedByApp: /** @type {string | null} */ (row.issued_by_app ?? null), + modified: /** @type {number} */ (row.modified ?? 0), + size: /** @type {number | null} */ (row.size ?? null), +}); diff --git a/src/puter-js/src/modules/FileSystem/operations/unshare.js b/src/puter-js/src/modules/FileSystem/operations/unshare.js new file mode 100644 index 0000000000..73db094b3d --- /dev/null +++ b/src/puter-js/src/modules/FileSystem/operations/unshare.js @@ -0,0 +1,46 @@ +import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; +import { defineOperation, firstDefined } from './scaffold.js'; +import { toShareItems, toShareRecipients } from './shareUtil.js'; + +/** @typedef {import('../types.js').UnshareOptions} UnshareOptions */ +/** @typedef {import('../types.js').ShareRecipient} ShareRecipient */ + +/** + * Withdraws a user's access to a file or directory. + * + * The item's owner can withdraw any share of it, whoever granted it. Anyone + * else can withdraw the shares they granted, or their own access — pass + * yourself as the recipient to leave a share someone else gave you. + * + * Resolves with the number of grants actually removed, which is `0` when there + * was nothing to withdraw. + * + * @type {{ + * (options: UnshareOptions): Promise<{ revoked: number }>, + * ( + * path: string, + * recipient: ShareRecipient, + * success?: (value: { revoked: number }) => void, + * error?: (reason: unknown) => void, + * ): Promise<{ revoked: number }>, + * }} + */ +const unshare = defineOperation({ + positional: ['path', 'recipient'], + request (options) { + return { + endpoint: '/share/revoke', + body: { + recipients: toShareRecipients( + firstDefined(options, 'recipient', 'recipients'), + ), + items: toShareItems(options, (path) => getAbsolutePathForApp(path)), + }, + transform: (/** @type {{ revoked?: number }} */ response) => ({ + revoked: Number(response.revoked ?? 0), + }), + }; + }, +}); + +export default unshare; diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js index 4c1324bf73..46aa8b83f6 100644 --- a/src/puter-js/src/modules/FileSystem/types.js +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -270,4 +270,103 @@ * | unknown[]} UploadItems */ +/** + * How much access a share grants. Stronger modes imply the weaker ones, so + * `write` also allows reading, and `manage` — the strongest — allows + * everything `write` does plus re-sharing the item with other people. + * + * @typedef {'see' | 'list' | 'read' | 'write' | 'manage'} ShareMode + */ + +/** + * Who a share is for. Give an `email` or a `username`; a bare string is read + * as an email when it contains `@` and a username otherwise. + * + * @typedef {string | { email?: string, username?: string }} ShareRecipient + */ + +/** + * One live share. + * + * @typedef {Object} Share + * @property {string} uid Identifier for this share. + * @property {ShareMode} mode Access the recipient has. + * @property {string} path Path of the shared item. + * @property {string} entryUid UID of the shared item. + * @property {boolean} isDir Whether the shared item is a directory. + * @property {string | null} name The item's name. Only set by `listShared()`. + * @property {string | null} type The item's content type, or `'folder'`. Only + * set by `listShared()`. + * @property {string | null} thumbnail URL of the item's thumbnail, if it has + * one. Only set by `listShared()`. + * @property {string | null} owner Username of the item's owner. Only set by + * `listShared()`. + * @property {string | null} issuer Username of whoever granted it. + * @property {string | null} holder Username of whoever received it. + * @property {string | null} [inheritedFrom] Shared ancestor this access comes from, if any. + * @property {string | null} [issuedByApp] UID of the app that asked for this + * share, or `null` when a person made it directly. + * @property {number} modified Last-modified time of the item, unix seconds. + * @property {number | null} size Size of the item in bytes; null for a directory. + */ + +/** + * @typedef {Object} ShareOptionsOwn + * @property {string} [path] Item to share. Relative paths resolve against the + * app's root directory. + * @property {string} [uid] Item to share, by UID. Use instead of `path`. + * @property {string[]} [paths] Several items to share in one call. + * @property {ShareRecipient | ShareRecipient[]} [recipient] Who to share with. + * @property {ShareRecipient | ShareRecipient[]} [recipients] Alias for + * `recipient`. + * @property {ShareMode} [mode] Access to grant. Defaults to `'read'`. + */ + +/** + * @typedef {ShareOptionsOwn & RequestCallbacks} ShareOptions + */ + +/** + * @typedef {Object} UnshareOptionsOwn + * @property {string} [path] Item to stop sharing. + * @property {string} [uid] Item to stop sharing, by UID. + * @property {ShareRecipient} [recipient] Who to withdraw access from. Pass + * yourself to leave a share someone else granted you. + */ + +/** + * @typedef {UnshareOptionsOwn & RequestCallbacks<{ revoked: number }>} UnshareOptions + */ + +/** + * @typedef {Object} ListSharedOptionsOwn + * @property {number} [limit] Maximum shares per page. + * @property {string} [cursor] Continuation token from a previous page. + * @property {boolean} [includeTotal] Include the total count in the response. + */ + +/** + * @typedef {ListSharedOptionsOwn & RequestCallbacks} ListSharedOptions + */ + +/** + * A page of shares. `cursor` is present only while more pages remain, so + * iterate until it is absent rather than counting items. + * + * @typedef {Object} SharePage + * @property {Share[]} items + * @property {string} [cursor] + * @property {number} [total] + */ + +/** + * @typedef {Object} GetSharesOptionsOwn + * @property {string} [path] Item to inspect. + * @property {string} [uid] Item to inspect, by UID. + */ + +/** + * @typedef {GetSharesOptionsOwn & RequestCallbacks} GetSharesOptions + */ + export {}; diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts index e3312b9023..5eda206c21 100644 --- a/src/puter-js/tests/api/suites/index.ts +++ b/src/puter-js/tests/api/suites/index.ts @@ -9,6 +9,7 @@ import kv from './kv.suite.ts'; import net from './net.suite.ts'; import os from './os.suite.ts'; import perms from './perms.suite.ts'; +import sharing from './sharing.suite.ts'; import system from './system.suite.ts'; import util from './util.suite.ts'; import workers from './workers.suite.ts'; @@ -28,6 +29,7 @@ export const suites: Suite[] = [ net, os, perms, + sharing, system, util, workers, diff --git a/src/puter-js/tests/api/suites/sharing.suite.ts b/src/puter-js/tests/api/suites/sharing.suite.ts new file mode 100644 index 0000000000..664026b361 --- /dev/null +++ b/src/puter-js/tests/api/suites/sharing.suite.ts @@ -0,0 +1,200 @@ +import { suite } from '../harness/types.ts'; +import type { TestContext } from '../harness/types.ts'; + +const home = (t: TestContext) => `/${t.env.users.user.username}`; + +/** A unique path under the acting user's home. */ +const scratch = (t: TestContext, label: string) => + `${home(t)}/sharing-${label}-${Math.random().toString(36).slice(2, 8)}.txt`; + +/** Read a file as the `other` user — plain fetch, so it works everywhere. */ +const readAsOther = (t: TestContext, path: string) => + fetch(`${t.env.apiOrigin}/read?${new URLSearchParams({ file: path })}`, { + headers: { + Authorization: `Bearer ${t.env.users.other.token}`, + Origin: t.env.apiOrigin, + }, + }); + +export default suite('sharing', { + 'share gives another user access, unshare takes it back': async (t) => { + const path = scratch(t, 'roundtrip'); + await t.puter.fs.write(path, 'shared content'); + + const before = await readAsOther(t, path); + t.assert.ok( + before.status !== 200, + `should not read before sharing (got ${before.status})`, + ); + + const shares = await t.puter.fs.share( + path, + t.env.users.other.username, + 'read', + ); + t.assert.equal(shares.length, 1); + t.assert.equal(shares[0].mode, 'read'); + t.assert.equal(shares[0].holder, t.env.users.other.username); + + const after = await readAsOther(t, path); + t.assert.equal(after.status, 200); + t.assert.equal(await after.text(), 'shared content'); + + const revoked = await t.puter.fs.unshare( + path, + t.env.users.other.username, + ); + t.assert.equal(revoked.revoked, 1); + + const afterRevoke = await readAsOther(t, path); + t.assert.ok( + afterRevoke.status !== 200, + `read should fail after unshare (got ${afterRevoke.status})`, + ); + }, + + 'share accepts an options object and defaults to read': async (t) => { + const path = scratch(t, 'options'); + await t.puter.fs.write(path, 'x'); + + const shares = await t.puter.fs.share({ + path, + recipient: { username: t.env.users.other.username }, + }); + t.assert.equal(shares[0].mode, 'read'); + t.assert.equal(shares[0].path, path); + }, + + 'getShares reports who can reach an item': async (t) => { + const path = scratch(t, 'getshares'); + await t.puter.fs.write(path, 'x'); + await t.puter.fs.share(path, t.env.users.other.username, 'write'); + + const shares = await t.puter.fs.getShares(path); + t.assert.equal(shares.length, 1); + t.assert.equal(shares[0].holder, t.env.users.other.username); + t.assert.equal(shares[0].mode, 'write'); + t.assert.equal(shares[0].issuer, t.env.users.user.username); + }, + + 'changing the mode replaces the share rather than adding one': async (t) => { + const path = scratch(t, 'remode'); + await t.puter.fs.write(path, 'x'); + + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + await t.puter.fs.share(path, t.env.users.other.username, 'write'); + + const shares = await t.puter.fs.getShares(path); + t.assert.equal(shares.length, 1); + t.assert.equal(shares[0].mode, 'write'); + }, + + 'listShared returns a page envelope with a total': async (t) => { + const path = scratch(t, 'listed'); + await t.puter.fs.write(path, 'x'); + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + + const page = await t.puter.fs.listShared({ includeTotal: true }); + t.assert.ok(Array.isArray(page.items), 'items should be an array'); + t.assert.equal(typeof page.total, 'number'); + // The sharer is not the holder, so their own item is not listed here. + t.assert.ok( + !page.items.some((share) => share.path === path), + 'sharer should not see their own item in shared-with-me', + ); + }, + + 'sharing an unknown recipient rejects': async (t) => { + const path = scratch(t, 'nobody'); + await t.puter.fs.write(path, 'x'); + + let failed = false; + try { + await t.puter.fs.share(path, 'no-such-user-zzz', 'read'); + } catch (e) { + failed = true; + t.assert.ok( + typeof (e as { code?: string }).code === 'string', + 'error should carry a code', + ); + } + t.assert.ok(failed, 'sharing with an unknown user should reject'); + }, + + 'a recipient gets a masked path that still resolves': async (t) => { + const path = scratch(t, 'masked'); + await t.puter.fs.write(path, 'masked content'); + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + + const page = await fetch( + `${t.env.apiOrigin}/share/shared-with-me?limit=200`, + { + headers: { + Authorization: `Bearer ${t.env.users.other.token}`, + Origin: t.env.apiOrigin, + }, + }, + ).then((r) => r.json() as Promise<{ items: Record[] }>); + + const listed = page.items.find((item) => item.uid_entry); + const shared = page.items.find( + (item) => item.name === path.split('/').pop(), + ); + t.assert.ok(listed && shared, 'the share should be listed'); + + // The exact masked shape: owner, entry uid, leaf name — and nothing + // of the owner's tree between them. The backend still resolves it. + t.assert.equal( + shared!.path, + `${home(t)}/${shared!.uid_entry}/${shared!.name}`, + ); + + const read = await fetch( + `${t.env.apiOrigin}/read?${new URLSearchParams({ file: shared!.path })}`, + { + headers: { + Authorization: `Bearer ${t.env.users.other.token}`, + Origin: t.env.apiOrigin, + }, + }, + ); + t.assert.equal(read.status, 200); + t.assert.equal(await read.text(), 'masked content'); + }, + + 'listShared carries what a file browser needs to render an item': async ( + t, + ) => { + const path = scratch(t, 'render'); + await t.puter.fs.write(path, 'x'); + await t.puter.fs.share(path, t.env.users.other.username, 'read'); + + const page = await fetch( + `${t.env.apiOrigin}/share/shared-with-me?limit=200`, + { + headers: { + Authorization: `Bearer ${t.env.users.other.token}`, + Origin: t.env.apiOrigin, + }, + }, + ).then((r) => r.json() as Promise<{ items: Record[] }>); + + const shared = page.items.find( + (item) => item.name === path.split('/').pop(), + ); + t.assert.ok(shared, 'the share should be listed'); + t.assert.equal(shared!.owner, t.env.users.user.username); + t.assert.equal(typeof shared!.type, 'string'); + }, + + 'unsharing something never shared reports nothing revoked': async (t) => { + const path = scratch(t, 'noop'); + await t.puter.fs.write(path, 'x'); + + const result = await t.puter.fs.unshare( + path, + t.env.users.other.username, + ); + t.assert.equal(result.revoked, 0); + }, +});