From 2c852bf6b35b309da4a38bd6245e83dbdcb4cb31 Mon Sep 17 00:00:00 2001 From: Juan Fernando Castro Date: Mon, 17 Aug 2026 09:08:23 -0400 Subject: [PATCH] :sparkles: PUT-1412 File sharing backend api (#3553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(permissions): drop hardcoded group permission map for a flat default * test(drivers): assert credential-gate intent instead of a 403 proxy * fix(permissions): report whether a revoke removed anything and persist the linked grant row before the flat view * feat(permissions): replicate permission invalidations across regions * feat(share): extend the share table into an index of active shares * feat(share): query and maintain active shares in ShareStore * feat(users): add a batched lookup by email * fix(cache): apply cache updates broadcast from peer regions * fix(permissions): scope a revoke to the issuer that granted it * feat(share): add ShareService with a per-day share limit A share is two writes that belong together: the permission grant, which authorizes access, and a share row, which makes it listable and ties it to an fsentry so it dies with the file. Nothing else grants fs:* to a user. Authorization reuses canManagePermission — an owner satisfies it through the is-owner implicator, a delegate through an explicit manage:fs: grant. An owner may clear any issuer's share of their node; anyone else only the ones they issued, or their own access. Self-revoke skips the manage gate but still requires `see`, so it cannot be used to probe for files. The per-day limit counts shares created rather than live rows, so revoking and re-sharing cannot recycle a slot, and changing an existing share's mode is not new reach and does not spend budget. Tunable via share_daily_limit. * feat(share): expose sharing over HTTP POST /share, POST /share/revoke, GET /share/shared-with-me, GET /share/shares. The controller was registered but entirely commented out. Recipients × items fan out concurrently — every pair is a distinct (holder, entry) key, so none of them contend — bounded by runWithConcurrencyLimitSettled, which returns results index-aligned with the input for the per-pair outcome list. Responses carry usernames only, never internal ids, and the 404-not-403 rule is preserved so a failed call cannot confirm a file the caller could not otherwise see. Notifications are fired off the response path; a share must not fail over its own notification. Per-request caps on recipients and items bound one call's fan-out; the daily limit bounds the total. * feat(share): keep recipients consistent when a shared item changes * fix(fs): stop listing issuer homes at the filesystem root * fix(acl): serialize concurrent mode changes on one node and pin app containment on shared paths * fix(fs): expire signed URLs over entries the signer doesn't own signFile defaults to a ~317k-year TTL and verifySignature checks only uid, expires and signature — never the ACL. A recipient who ever signed a shared file therefore held a permanent, revocation-proof URL to its bytes: revoking the share did nothing to it. signEntry now takes the acting user and drops to NON_OWNER_SIGNATURE_TTL_SECONDS (1 hour) when the signer is not the entry's owner. Owners keep the permanent default, so no existing client changes behavior. The signature-authenticated directory listing bounds its children unconditionally: that route has no session actor, and a signature proves possession rather than ownership, so a recipient holding a short-lived directory signature could otherwise mint permanent URLs for every child. A bounded window is not revocation — the durable fix is a per-entry signature epoch folded into the HMAC and bumped on any permission change. * refactor(permissions): drop the unused permission-issuer lookup listUserPermissionIssuers and its store method listUserPermissionIssuerIds existed to synthesize the filesystem root from the home directories of everyone who had granted the caller a permission. That listing is gone — it advertised folders readdir then refused to open — and the share index answers "who shared with me" directly, so nothing wants them back. One removed test only asserted that the call returned an array; the other covered readLinkedUserUserPerms round-tripping and is kept, rewritten without the issuer lookup. * fix(share): return the created share, not just an acknowledgement * feat(puter.js): add file sharing to puter.fs share(), unshare(), listShared() and getShares() on puter.fs, following the existing FS operation shape: positional and options-object forms through defineOperation, JSDoc overloads as the published signature, relative paths resolved against the app's root directory. A bare recipient string is read as an email when it contains @ and as a username otherwise. Sharing an item with someone who already has it replaces their access rather than stacking a second grant, so raising read to write is one more call. Adds a sharing suite to the API runner, which passes unchanged on node, browser and workerd. Documents all four methods with runnable examples, and corrects the FS overview callout that told readers one user cannot read another's files — true before this, not after. * feat(gui): add a Shared folder for items others shared with you A sidebar entry listing everything other users have shared with you, backed by puter.fs.listShared(). The path is the sentinel `puter://shared` rather than //Shared: this is a query, not a directory, and a path-shaped value could collide with a folder someone actually creates. refresh_item_container and update_window_path both branch on it to skip the stat there is no fsentry for, and the listing swaps readdir for listShared. Entries render at their real paths under their owners' directories — the item container already preferred an explicit fsentry.path over joining onto the container, so nothing else had to change. Each carries who shared it and at what level, which the context menu reads next. * feat(gui): share items from the context menu A sharing dialog shaped like its neighbours — options object, HTML-string template, jQuery wiring, delegating to UIWindow() — with a recipient field, a read/edit/share dropdown, and the current access list with revoke buttons. Reached from a new "Share…" context menu entry, which is hidden on items shared *with* you: re-sharing needs manage, so the dialog would only surface an error. Those items get "Remove from Shared" in place of Delete. Delete moves an item to *your* trash, which for someone else's file means moving their data out of their tree — FSService refuses it, and the user saw a bare 403. Removing your own access is what the action was reaching for, so that is what it now does. * fix(share): withdraw what a removed recipient re-shared * feat(share): report access inherited from a parent folder * fix(gui): load the puter.js bundle the server configured * refactor(gui): extract the action icon set into a helper * feat(gui): surface Shared in the file browser * feat(gui): manage access from the share dialog * test(share): cover access inherited from a parent folder * fix(share): keep downstream access from surviving a delegate who leaves * fix(gui): name the real owner in the share dialog * fix(gui): page through every shared item instead of the first 50 * feat(share): return item metadata with a share * fix(share): invalidate a holder's cache when the entry is deleted * fix(gui): treat items inside a shared folder as someone else's * feat(permissions): let manage inherit down the filesystem tree Access already reached descendants through the ancestor chain while authority did not, so someone trusted to manage a shared folder could re-share the folder but nothing inside it, and could not see who had access to a file within it. A manage-inherits-from-ancestor implicator resolves it in the permission layer, beside is-owner, so every caller agrees rather than just ShareService. It consults only the immediate parent — resolving that re-enters one level up, making a chain of depth d cost d checks rather than d². That makes two cascade gaps reachable, both fixed here. A revoke now walks the subtree, since a grant on a descendant can rest on authority held at the folder. And it stops at a delegate whose authority survives another issuer, because what they granted was never theirs to lose. Also pins that manage is not transitive: granting it needs manage:manage:fs:, which only the owner holds, so delegation is one level deep by construction. * fix(gui): offer sharing inside a folder you manage The menus encoded "manage does not inherit" and would now hide an action that works. The Shared listing records each root's mode; the menus resolve a child's by longest matching ancestor, loading on demand so a deep link or restored window works too. * fix(share): make the daily share limit hold under concurrency * test(share): cover concurrency, measure cost, and name cases for what they verify * fix(gui): import the ownership helpers the item menu calls The single-item context menu handler calls is_owned_by_me and shared_mode_for, but the imports were only ever added to generate_file_context_menu.js — so every right-click on an item threw a ReferenceError before the menu could build, and the non-owner Delete gating never ran. Co-Authored-By: Claude Fable 5 * fix(share): authorize before resolving the recipient share() looked up the recipient in parallel with the entry, before the manage check — and the two failures carried different error codes. Any verified user with a real entry uid could probe arbitrary emails and usernames for account existence, at no quota cost. Resolve the entry, authorize, and only then resolve the recipient: an unauthorized caller now sees the identical safe 404 whether or not the recipient exists. Co-Authored-By: Claude Fable 5 * fix(permissions): broadcast permission row-cache invalidations to peer regions Every publishCacheKeys call for the u2u, u2a, and access-token row caches omitted broadcast, so a revoke only cleared the mutating region's Redis. A peer region applied the replicated generation bump, re-scanned, read the deleted row from its own still-warm 5-minute row cache, and re-warmed the flat view from it — revoked access outlived the revoke by the row-cache TTL instead of the intended 60-second bound. CacheReplicationService already consumes these events; the emits were just never sent. Co-Authored-By: Claude Fable 5 * fix(fs): refuse to rename an entry owned by another user remove and move both refuse to act on an entry the caller does not own, even when the ACL allows the write — rename had no such guard, so a write-mode share recipient could rename the owner's file, or the shared folder itself, rewriting the owner's whole subtree's paths. rename now takes the acting user and applies the same policy. Co-Authored-By: Claude Fable 5 * fix(permissions): decide a flat delete from the primary, not a lagging replica revokeUserUserPermission deletes the SQL grant, then only drops the flat KV entry once no issuer still grants the permission. That remaining-check read through the row cache the delete had just invalidated, straight to a replica — under any lag the deleted row reappeared, the flat delete was skipped, and the stale rows were re-cached for another five minutes. Grant-path flat entries carry no TTL, so the holder kept working access with zero SQL rows behind it, invisible to every listing. The check now reads the primary and re-warms the cache with what it actually saw. Co-Authored-By: Claude Fable 5 * fix(permissions): keep a failed remote flat-invalidation from crashing the process The outer.permission.flatInvalidated applier was fire-and-forget with no catch, and it awaits a KV delete — one transient KV error while applying a peer region's revoke became an unhandled rejection, which is process-fatal under default Node. Its sibling appliers were already guarded; this one now logs and moves on, leaving the entry to the next invalidation or its TTL, same as a lost event. Co-Authored-By: Claude Fable 5 * fix(share): revoke every requested item and recipient, not just the first revokeShare destructured only the first recipient and first item while the parsers accept arrays up to the request caps — unshare({items: [a, b, c]}) returned success having revoked only a, leaving access the caller believes is gone. Revoke now fans out over every (recipient, item) pair exactly like POST /share, reports per-pair outcomes, and sums the revoked count; the response stays backward compatible. Co-Authored-By: Claude Fable 5 * fix(share): only a confirmed email designates a recipient Recipient resolution by email accepted unconfirmed accounts, so pre-registering someone else's address (unconfirmed) was enough to receive shares meant for them once no confirmed account held it. An email now only resolves to an account that has confirmed it; username shares are unaffected. Co-Authored-By: Claude Fable 5 * fix(share): accept tilde-rooted paths like the FS routes do The SDK resolves relative paths to ~/..., but the share routes never expanded the tilde — a ~-prefixed string was read as a uid and every relative-path call 404'd. Item parsing now treats ~ as path-shaped and expands it to the actor's home with the same helper the legacy FS routes use, on share, revoke, and the shares listing. Co-Authored-By: Claude Fable 5 * fix(share): walk a directory revoke by parent linkage, not path prefix listByFsentrySubtree matched descendants with fsentry_id = ? OR path LIKE ?, which has two problems: fsentries.path is lazily backfilled and NULL on old rows, so those descendants' shares silently survived a directory revoke, and the OR'd predicates forced a scan of every active share. A recursive CTE over parent_id — the same shape the lineage resolver already uses — covers every descendant and runs on idx_parentId_name. Co-Authored-By: Claude Fable 5 * fix(gui): give each item its own share dialog single_instance keyed the dialog on the app id alone, so opening Share… on a second file focused the first file's dialog — typing a recipient there granted access to the wrong file, with only the title hinting at it. The dialog is now instanced per path: same item refocuses, different item opens fresh. Also stops pre-encoding the title, which UIWindow encodes again. Co-Authored-By: Claude Fable 5 * feat(permissions): let manage answer a write check A manage grant let its holder re-share a folder but not work in it: the ACL mode family stops at write, and the fs exploder had no rule for the narrowest mode, so `manage:fs:` never satisfied `fs::write`. Fold manage into the candidate list for every non-manage mode, in both the access-token branch and the scan branch, and give `write` an (empty) exploder rule so the manage arm is emitted for it too. * fix(fs): authorize restructuring by write on the parent * fix(fs): let a share recipient work inside a shared folder rename, remove and move refused outright when the entry belonged to someone else, so a recipient with write could neither delete nor rename anything inside a folder shared with them. The GUI compounded it by hiding Delete for any item it did not own. Authorize the three by ACL write on the entry's parent. For an owner that is the same answer; for a recipient it grants the inside of a shared folder and withholds the folder itself, whose parent is the owner's private tree. Deleting sends the item to its owner's trash rather than the deleter's, so it leaves the recipient's view without leaving the owner's account and without changing hands. A move may not otherwise carry someone else's entry out of their tree. * fix(fs): give a new entry to the owner of the folder it lands in A file a share recipient added to a shared folder was recorded as theirs while living in the owner's tree, so a subtree could hold rows belonging to several people — and the storage it consumed was checked against the writer while being counted against the owner. Take the owner from the parent row at every insert, charge the allowance to that owner, and hand a moved entry over to the tree it moves into. An entry now always belongs to whoever owns the directory holding it. * feat(fs): address shared entries as ~/share/ A recipient could read the owner's whole path off any shared entry — where they keep the file and what sits beside it, neither of which the share is about. Give shares their own namespace. `~/share//rel/path` resolves to the real path on the way in, and outgoing paths are rewritten to it on the way out. Entries the actor owns pass through untouched, so no existing client contract moves. * revert(fs): mask only the directory bar, not the addressing fe535d598 made `~/share/` the actual address for every shared entry. That reached far past the intent: item names became uuids, the Shared views rendered uuids instead of filenames, and navigation addressed entries through a namespace nothing else understood. Put real paths back everywhere — responses, the share listing, and request handling — and do the masking where it was wanted, in the window's directory bar. A recipient sees `Shared › Contents › sub` while every crumb keeps the real path it navigates to. The share listing now carries the entry's name, content type, owner and a signed thumbnail. A share row has no fsentry behind it for a client to stat, and the stored thumbnail is an `s3://bucket/key` URI that no client can render and none should see. Co-Authored-By: Claude Opus 5 (1M context) * fix: path obfuscation, webdav + small ui stuff * test(share): assert the masked share path by its exact shape The substring check tripped on the scratch files' own names, which start with `sharing-`; the exact-equality assertion on `///` already proves nothing above the share leaks. Co-Authored-By: Claude Fable 5 * fix(share): rename policy, shared-view guards, webdav share parent, quota/bucket invariants - Rename: a directly-shared FILE renames with write on it; a shared folder root stays fixed (its name is the owner's tree structure). GUI can_rename mirrors the backend, guards all editor entry points. - Up from a share root goes to the Shared view on both surfaces; the Shared view gets a single crumb, a disabled Up button, and refuses drops, New/Paste, uploads and ctrl+V everywhere (it is a query, not a directory). - WebDAV: PROPFIND on /owner/uuid answers as a virtual collection holding the share root (ACL-gated; 404 for strangers). - Storage allowance override no longer crosses user boundaries: a recipient's plan cannot raise the owner's cap. - Overwrites stay in the bucket the entry already lives in instead of repointing to the handling server's bucket and stranding the old object. - manage mode documented as implying write (matches enforcement); share dialog label now "Can edit & share". Documented that fs socket events are owner-only. - Sidebar: saved orders gain the Shared entry once the user has shares. Co-Authored-By: Claude Fable 5 * fix(share): close review findings, and retire /auth/grant-user-user Security - Recipient writes no longer echo the owner's real path back. The pending-write event and the upload-progress meta both go to the *acting* user, so a write into a shared folder handed out the layout above the share root. - `maskerFor` failed open: the first caller fixed the actor, and one that ran before the request knew who was acting pinned `undefined` — after which every path published unmasked. It now adopts the first real actor and rebuilds for a different one. - `/auth/grant-user-user` returns 501. It wrote user-to-user grants straight to the permission tables with no share row, so nothing could list or cascade a revoke over them — and the new `manage` mode meant a delegate could reach it for the owner's files. Filesystem access goes through `/share`; nothing else is meant to pass between two users. Undocumented (its docs page was never written) and no callers. Revoking is untouched, so grants made before this can still be withdrawn. - `#assertCanManage` asks about the permission actually being granted rather than a fixed `read`, so sharing at `manage` is refused at the share layer instead of by `grantUserUserPermission` two levels down. - `listSharedWithMe` checks the grants, not just the index: withdrawing access any other way left the row publishing name, size and a signed thumbnail URL. - Moving an item into another tree now retires its shares. Grants are keyed on uuid, so they followed it and left the new owner with recipients they never agreed to. Correctness - `acl.check` gets the real path again, not the masked one. ACL matches on the path string, and a mask hides the `AppData/` shape it needs. - "Leave this share" works for a grant that predates the index; the fallback scoped the delete to the caller as issuer, which can never match. - `move`/`copy` reject a name with a slash or a `.`/`..` segment, as `rename` already did. It matters more here: a move into the owner's Trash skips the destination write check. - Descendant walks scope by path, not by owner, so rows predating the one-owner-per-subtree invariant aren't orphaned by their parent's deletion. Performance - Index `user_to_user_permissions(permission)`. Retiring a node's grants is a prefix match, but the primary key is (issuer, holder, permission), so it was a full scan. - Retirement is coalesced and chunked. `remove()` emits one event per descendant, so deleting a directory fired one unindexed lookup per file, all at once and unawaited. - `listReaching` is a plain `fsentry_id IN (...)` on `idx_share_fsentry`. It runs behind every file write, and the join-plus-OR it replaced was unindexable. --------- Co-authored-by: Claude Fable 5 Co-authored-by: Daniel Salazar --- .../database/SqliteDatabaseClient.test.ts | 2 +- .../clients/database/SqliteDatabaseClient.ts | 1 + .../migrations/mysql/mysql_mig_22.sql | 106 ++ .../migrations/postgres/postgres_mig_11.sql | 42 + .../migrations/sqlite/0067_share_entries.sql | 60 + src/backend/clients/event/types.ts | 36 +- .../controllers/auth/AuthController.test.ts | 87 +- .../controllers/auth/AuthController.ts | 30 +- src/backend/controllers/fs/FSController.ts | 107 +- .../controllers/fs/LegacyFSController.test.ts | 116 +- .../controllers/fs/LegacyFSController.ts | 109 +- .../controllers/fs/legacyFsHelpers.test.ts | 47 + src/backend/controllers/fs/legacyFsHelpers.ts | 72 +- .../share/ShareController.http.test.ts | 303 ++++ .../controllers/share/ShareController.ts | 767 ++++---- .../webdav/WebDAVController.test.ts | 121 ++ .../controllers/webdav/WebDAVController.ts | 111 +- src/backend/services/acl/ACLService.test.ts | 195 ++ src/backend/services/acl/ACLService.ts | 158 +- .../cache/CacheReplicationService.test.ts | 84 + .../services/cache/CacheReplicationService.ts | 60 + src/backend/services/fs/FSService.test.ts | 560 +++++- src/backend/services/fs/FSService.ts | 406 ++++- src/backend/services/fs/resolveNode.test.ts | 40 + src/backend/services/fs/resolveNode.ts | 17 + src/backend/services/fs/rootListing.test.ts | 13 +- src/backend/services/fs/rootListing.ts | 21 +- src/backend/services/fs/sharePathMask.test.ts | 228 +++ src/backend/services/fs/sharePathMask.ts | 264 +++ .../services/homepage/PuterHomepageService.ts | 4 + src/backend/services/index.ts | 9 + .../permission/PermissionService.test.ts | 271 ++- .../services/permission/PermissionService.ts | 125 +- .../services/share/ShareConsistency.test.ts | 978 ++++++++++ .../services/share/ShareService.test.ts | 1597 +++++++++++++++++ src/backend/services/share/ShareService.ts | 1322 ++++++++++++++ src/backend/stores/fs/FSEntryStore.test.ts | 9 +- src/backend/stores/fs/FSEntryStore.ts | 65 +- .../stores/permission/PermissionStore.test.ts | 351 +++- .../stores/permission/PermissionStore.ts | 271 ++- src/backend/stores/share/ShareStore.js | 315 +++- src/backend/stores/share/ShareStore.test.js | 272 +++ src/backend/stores/user/UserStore.test.ts | 4 + src/backend/types.ts | 15 + src/backend/util/fileSigning.ts | 10 + src/docs/src/FS.md | 6 +- src/docs/src/FS/getShares.md | 84 + src/docs/src/FS/listShared.md | 93 + src/docs/src/FS/share.md | 152 ++ src/docs/src/FS/unshare.md | 89 + src/docs/src/sidebar.js | 32 + src/gui/src/UI/Dashboard/TabFiles.js | 111 +- src/gui/src/UI/UIItem.js | 85 +- src/gui/src/UI/UIWindow.js | 72 +- src/gui/src/UI/UIWindowShare.js | 239 +++ src/gui/src/css/style.css | 120 ++ src/gui/src/globals.js | 5 + src/gui/src/helpers.js | 42 +- src/gui/src/helpers/actionIcons.js | 43 + .../src/helpers/generate_file_context_menu.js | 68 +- src/gui/src/helpers/list_all_shared.js | 46 + src/gui/src/helpers/path_owner.js | 55 + src/gui/src/helpers/refresh_item_container.js | 47 +- src/gui/src/helpers/share_paths.js | 85 + src/gui/src/helpers/share_paths.test.js | 115 ++ src/gui/src/helpers/shared_access.js | 142 ++ src/gui/src/helpers/shared_access.test.js | 153 ++ src/gui/src/i18n/translations/en.js | 22 + src/gui/src/icons/folder-shared.svg | 9 + src/gui/src/icons/sidebar-folder-shared.svg | 9 + src/gui/src/index.js | 2 +- src/gui/src/keyboard.js | 5 + src/puter-js/index.d.ts | 8 + src/puter-js/src/modules/FileSystem/index.js | 10 + .../FileSystem/operations/getShares.js | 42 + .../FileSystem/operations/listShared.js | 44 + .../modules/FileSystem/operations/share.js | 60 + .../FileSystem/operations/shareUtil.js | 78 + .../modules/FileSystem/operations/unshare.js | 46 + src/puter-js/src/modules/FileSystem/types.js | 99 + src/puter-js/src/modules/perms/grants.js | 4 + src/puter-js/tests/api/suites/index.ts | 2 + src/puter-js/tests/api/suites/perms.suite.ts | 56 +- .../tests/api/suites/sharing.suite.ts | 200 +++ 84 files changed, 11459 insertions(+), 902 deletions(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_22.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_11.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0067_share_entries.sql create mode 100644 src/backend/controllers/share/ShareController.http.test.ts create mode 100644 src/backend/services/cache/CacheReplicationService.test.ts create mode 100644 src/backend/services/cache/CacheReplicationService.ts create mode 100644 src/backend/services/fs/sharePathMask.test.ts create mode 100644 src/backend/services/fs/sharePathMask.ts create mode 100644 src/backend/services/share/ShareConsistency.test.ts create mode 100644 src/backend/services/share/ShareService.test.ts create mode 100644 src/backend/services/share/ShareService.ts create mode 100644 src/docs/src/FS/getShares.md create mode 100644 src/docs/src/FS/listShared.md create mode 100644 src/docs/src/FS/share.md create mode 100644 src/docs/src/FS/unshare.md create mode 100644 src/gui/src/UI/UIWindowShare.js create mode 100644 src/gui/src/helpers/actionIcons.js create mode 100644 src/gui/src/helpers/list_all_shared.js create mode 100644 src/gui/src/helpers/path_owner.js create mode 100644 src/gui/src/helpers/share_paths.js create mode 100644 src/gui/src/helpers/share_paths.test.js create mode 100644 src/gui/src/helpers/shared_access.js create mode 100644 src/gui/src/helpers/shared_access.test.js create mode 100644 src/gui/src/icons/folder-shared.svg create mode 100644 src/gui/src/icons/sidebar-folder-shared.svg create mode 100644 src/puter-js/src/modules/FileSystem/operations/getShares.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/listShared.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/share.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/shareUtil.js create mode 100644 src/puter-js/src/modules/FileSystem/operations/unshare.js create mode 100644 src/puter-js/tests/api/suites/sharing.suite.ts diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 209da6262..c00a7d6eb 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 6877d16c4..5e9e8550f 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 000000000..fac8bd9ae --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_22.sql @@ -0,0 +1,106 @@ +-- 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; + + -- Retiring a deleted node's grants looks them up by permission text + -- (`fs:` and `fs::%`); the subject lives in that text rather + -- than a column, so no foreign key can cascade it. The primary key is + -- (issuer, holder, permission), which never puts `permission` first, so + -- without this both the equality and the left-anchored LIKE degrade to a + -- full scan on the delete path. The column is ascii varchar(255), well + -- inside InnoDB's key limit, so it is indexed whole. + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_to_user_permissions' + AND INDEX_NAME = 'idx_user_to_user_permissions_permission' + ) THEN + ALTER TABLE `user_to_user_permissions` + ADD INDEX `idx_user_to_user_permissions_permission` (`permission`); + 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 000000000..b56ad1017 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_11.sql @@ -0,0 +1,42 @@ +-- 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); + +-- Retiring a deleted node's grants looks them up by permission text, and the +-- primary key is (issuer, holder, permission) — `permission` is never the +-- leading column, so the delete path would otherwise scan the table. The +-- text_pattern_ops class is what makes the left-anchored LIKE a range scan +-- under a non-C collation; plain equality still uses it. +CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_permission + ON user_to_user_permissions (permission text_pattern_ops); 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 000000000..3ea838f0d --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0067_share_entries.sql @@ -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 . + +-- 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`); + +-- Retiring a deleted node's grants looks them up by permission text +-- (`fs:` and `fs::%`), and the subject only lives in that text — +-- no column, so no foreign key can cascade it. The table's primary key is +-- (issuer, holder, permission), which never puts `permission` first, so +-- without this the equality and the left-anchored LIKE both degrade to a full +-- scan on the delete path. With it, each is a range scan. +CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_permission` + ON `user_to_user_permissions` (`permission`); diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 7e137d3fc..e38d9468e 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -290,7 +290,17 @@ export type EventMap = { sourceObjectKey: string; copyObjectKey: string; }; - 'fs.move.node': { node: FSEntry; fromPath: string; toPath: string }; + /** + * `fromUserId` is the owner the node had before the move. A move into + * someone else's tree re-owns the row, and `node` already carries the new + * owner — listeners that care about the hand-over need the old one. + */ + 'fs.move.node': { + node: FSEntry; + fromPath: string; + toPath: string; + fromUserId?: number; + }; 'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry }; 'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry }; 'fs.storage.upload-progress': { @@ -344,6 +354,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 @@ -542,10 +567,11 @@ export type EventKey = keyof EventMap & string; // Generates a wildcard for every non-final dot-separated prefix of K. export type WildcardPrefixes = K extends `${infer Head}.${infer Tail}` - ? | `${Head}.*` - | (Tail extends `${string}.${string}` - ? `${Head}.${WildcardPrefixes}` - : never) + ? + | `${Head}.*` + | (Tail extends `${string}.${string}` + ? `${Head}.${WildcardPrefixes}` + : never) : never; export type ListenKey = EventKey | WildcardPrefixes; diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 01b50f9d7..a55166ab9 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -1681,57 +1681,25 @@ describe('AuthController grant flows', () => { } as Actor; }); - it('grant-user-user: rejects missing target_username/permission with 400', async () => { + it('grant-user-user: is retired and reports 501', async () => { + // Filesystem access goes through /share, which indexes the grant so it + // can be listed and revoked; nothing else is meant to pass between two + // users. Revoking is untouched, so pre-existing grants can still go. await expect( - controller.handleGrantUserUser( - makeReq({ permission: 'fs:read' }, { actor: issuerActor }), - makeRes(), - ), - ).rejects.toMatchObject({ statusCode: 400 }); - }); - - it('grant-user-user: persists the permission and PermissionService.check sees it', async () => { - const permission = `service:test-grant-${uuidv4()}:ii:read`; - // The controller calls PermissionService.grantUserUserPermission, - // which gates on `manage:` for non-system actors. Pre- - // bootstrap the manage flag directly through the permission store - // (the system actor would skip this gate, but its in-memory shape - // has no user.id, so it can't issue grants). Then the controller - // call exercises persist + check end-to-end. - await server.stores.permission.setFlatUserPerm( - issuer.id, - `manage:${permission}`, - { - permission: `manage:${permission}`, - deleted: false, - issuer_user_id: issuer.id, - } as never, - ); - - const res = makeRes(); - await inCtx(issuerActor, () => controller.handleGrantUserUser( makeReq( { target_username: target.username, - permission, - extra: { reason: 'unit-test' }, + permission: `service:test-${uuidv4()}:ii:read`, }, { actor: issuerActor }, ), - res, + makeRes(), ), - ); - expect(res.body).toEqual({}); - - // The target now sees the permission via the user-to-user grant. - const targetActor = { - user: { ...target, email_confirmed: true }, - } as Actor; - const granted = await server.services.permission - .check(targetActor, permission) - .catch(() => false); - expect(granted).toBeTruthy(); + ).rejects.toMatchObject({ + statusCode: 501, + legacyCode: 'not_implemented', + }); }); it('grant-user-app: persists a user→app permission grant', async () => { @@ -4834,14 +4802,13 @@ describe('AuthController permission revokes', () => { issuer_user_id: issuer.id, } as never, ); - // Grant first. + // Grant first — through the service, since the grant route is retired + // and revoking has to keep working for what it left behind. await inCtx(issuerActor, () => - controller.handleGrantUserUser( - makeReq( - { target_username: target.username, permission }, - { actor: issuerActor }, - ), - makeRes(), + server.services.permission.grantUserUserPermission( + issuerActor, + target.username, + permission, ), ); @@ -4927,9 +4894,10 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () => ); // A user→user grant must show up under `myself_to_user` for the - // issuer and `user_to_myself` for the holder. Grants gate on - // `manage:`, so bootstrap that flag first (mirrors the - // grant-user-user persistence test). + // issuer and `user_to_myself` for the holder. Seeded through the + // service: the grant route is retired, but the listing still has to + // report grants made before that, and the ones sharing writes. + // Grants gate on `manage:`, so bootstrap that flag first. const { user: holder, actor: holderActor } = await makeUserAndActor(); const userPermission = 'service:tl-user:ii:read'; await server.stores.permission.setFlatUserPerm( @@ -4942,16 +4910,11 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () => } as never, ); await inCtx(actor, () => - controller.handleGrantUserUser( - makeReq( - { - target_username: holder.username, - permission: userPermission, - extra: {}, - }, - { actor }, - ), - makeRes(), + server.services.permission.grantUserUserPermission( + actor, + holder.username, + userPermission, + {}, ), ); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 46fc9d50b..b5c1409af 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -1150,7 +1150,8 @@ export class AuthController extends PuterController { is_temp: user!.password === null && user!.email === null, ip: (req?.headers?.['x-forwarded-for'] as - string | undefined) || + | string + | undefined) || ( req as unknown as { connection?: { remoteAddress?: string }; @@ -2965,28 +2966,23 @@ export class AuthController extends PuterController { // -- Permission grants ------------------------------------------- + // Retired. Filesystem access is shared through `/share`, which indexes the + // grant so the owner can see and revoke it; nothing else is meant to pass + // between two users. This wrote straight to the permission tables with no + // such record. Never documented, no callers. Revoking still works, so + // anything granted before this can still be taken back. @Post('/auth/grant-user-user', { subdomain: 'api', requireUserActor: true, rateLimit: GRANT_LIMIT, }) - async handleGrantUserUser(req: Request, res: Response): Promise { - const { target_username, permission, extra, meta } = req.body; - if (!target_username || !permission) { - throw new HttpError( - 400, - 'Missing `target_username` or `permission`', - { legacyCode: 'bad_request' }, - ); - } - await this.services.permission.grantUserUserPermission( - req.actor!, - target_username, - permission, - extra, - meta, + async handleGrantUserUser(_req: Request, _res: Response): Promise { + throw new HttpError( + 501, + 'Direct user-to-user permission grants are no longer supported; ' + + 'use puter.fs.share() to share files', + { legacyCode: 'not_implemented' }, ); - res.json({}); } /** diff --git a/src/backend/controllers/fs/FSController.ts b/src/backend/controllers/fs/FSController.ts index d2b678d8b..f07c0b609 100644 --- a/src/backend/controllers/fs/FSController.ts +++ b/src/backend/controllers/fs/FSController.ts @@ -25,7 +25,15 @@ 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, + maskPathForRequest, + resolveSharePath, +} from '../../services/fs/sharePathMask.js'; import type { PreparedBatchWrite, UploadedBatchWriteItem, @@ -144,7 +152,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 +223,7 @@ export class FSController extends PuterController { req.body, ); normalizedRequestBody.fileMetadata = - this.#normalizeFileMetadataPath( + await this.#normalizeFileMetadataPath( req, normalizedRequestBody.fileMetadata, normalizedRequestBody, @@ -435,7 +443,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 +457,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 +563,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 +594,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 +844,7 @@ export class FSController extends PuterController { req.body, ); normalizedRequestBody.fileMetadata = - this.#normalizeFileMetadataPath( + await this.#normalizeFileMetadataPath( req, normalizedRequestBody.fileMetadata, normalizedRequestBody, @@ -994,7 +1007,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 +1123,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 +1397,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 +1438,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 +1479,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 +1524,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 +1638,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 +2193,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 +2207,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 +2249,7 @@ export class FSController extends PuterController { return normalizedPath; } - #normalizeFileMetadataPath( + #expandFileMetadataPath( req: Request, fileMetadata: FSEntryWriteInput | undefined, fallbackSource?: unknown, @@ -2236,13 +2264,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 +2374,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,14 +2537,19 @@ export class FSController extends PuterController { requestBody: SignedWriteRequest, response: SignedWriteResponse, ): Promise { - const normalizedPath = this.#normalizePath( + const normalizedPath = await this.#resolveClientPath( requestBody.fileMetadata.path, ); + // This goes to the *acting* user, not the owner, so a recipient writing + // into a shared folder would otherwise be handed the owner's real path + // — the one thing the masking exists to withhold. There is no row yet, + // so the mask is built from the path the caller already named. + const publishedPath = maskPathForRequest(normalizedPath); const pendingResponse = { id: response.objectKey, uid: response.objectKey, uuid: response.objectKey, - path: normalizedPath, + path: publishedPath, name: pathPosix.basename(normalizedPath), is_dir: false, content_type: response.contentType, @@ -2774,7 +2832,10 @@ export class FSController extends PuterController { user_id: userId, userId: userId, item_uid: itemUid, - item_path: itemPath, + // Progress is reported to the acting user, who is not the + // owner when the write lands in a shared folder — publish + // the path in the form they addressed it by. + item_path: maskPathForRequest(itemPath), ...this.#toEventGuiMetadata(guiMetadata), }, }, diff --git a/src/backend/controllers/fs/LegacyFSController.test.ts b/src/backend/controllers/fs/LegacyFSController.test.ts index 7b235ff59..8969e7ac6 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 2d8ae6dfc..884ea350c 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: [], }; @@ -1331,6 +1339,11 @@ export class LegacyFSController extends PuterController { ); let finalAction: 'read' | 'write' = 'read'; if (action === 'write') { + // The real path, not the masked one: ACL reads the path + // string itself for its short-circuits (own home, an app's + // AppData under another user), and a mask hides the + // `AppData/` shape those match on. Nothing here + // publishes the descriptor, so there is nothing to hide. const writeOk = await this.services.acl.check( actor, { @@ -1354,12 +1367,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 +1486,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 +1517,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 +1539,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 +1618,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 +1685,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; @@ -1741,6 +1790,7 @@ export class LegacyFSController extends PuterController { // read. `/writeFile`'s ACL re-check would still block the write, but // returning `write_url` to a read-only caller is the same // privilege-leak shape that `/sign` and `/readdir` strip. + // Real path — see the note on the other `acl.check` above. const writeOk = await this.services.acl.check( actor, { @@ -1754,7 +1804,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 +1827,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 +1892,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 +2351,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 f786f0121..51021ec6a 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 06066e292..1f8a36aaa 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 000000000..cd69f17fb --- /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 03a8661fa..c9b211cf2 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; /** - * Share link endpoints — check, apply, and request access to pending shares. - * The main `POST /share` creation endpoint is also here. - * - * 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. + * 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; +} + +/** + * Sharing endpoints. `ShareService` owns the semantics; this layer parses + * input, bounds fan-out, and shapes responses. + * + * 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. + /** + * 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); - // #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`'); - // } + const pairs = recipients.flatMap((recipient) => + items.map((item) => ({ recipient, item })), + ); - // 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 settled = await runWithConcurrencyLimitSettled( + pairs, + SHARE_CONCURRENCY, + ({ recipient, item }) => + this.services.share.unshare(actor, { ...item, recipient }), + ); - // const share = await this.stores.share.getByUid(decoded.uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); + 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), + }; + }); - // res.json({ - // $: 'api:share', - // uid: share.uid, - // email: share.recipient_email, - // }); - // }; + const succeeded = results.filter((r) => r.status === 'success').length; + res.json({ + status: + succeeded === results.length + ? 'success' + : succeeded > 0 + ? 'mixed' + : 'aborted', + revoked, + results, + }); + } - // // -- POST /sharelink/apply --------------------------------------- - // // Auth required — apply a pending share's permissions to the caller. + /** + * 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); - // #apply = async (req: Request, res: Response): Promise => { - // const uid = req.body?.uid; - // if (typeof uid !== 'string') throw new HttpError(400, 'Missing `uid`'); + 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', + }); - // const actor = req.actor; - // if (!actor?.user) throw new HttpError(401, 'Unauthorized'); + res.json({ + items: await Promise.all( + page.items.map((share) => this.#toClientShare(share)), + ), + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(page.total !== undefined ? { total: page.total } : {}), + }); + } - // const share = await this.stores.share.getByUid(uid); - // if (!share) throw new HttpError(404, 'Share not found or expired'); + /** 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', + }); + } - // // 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'); + const shares = await this.services.share.listSharesOf(actor, target); + res.json({ + items: await Promise.all( + shares.map((share) => this.#toClientShare(share)), + ), + }); + } - // // 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', - // ); - // } + // -- Helpers ------------------------------------------------------ - // // 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', - // ); - // } + /** + * 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, + }; + } - // // 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); - // } - // } + #requireActor(req: Request): Actor { + const actor = req.actor; + if (!actor?.user) + throw new HttpError(401, 'Unauthorized', { + legacyCode: 'unauthorized', + }); + return actor; + } - // // Share consumed — delete it - // await this.stores.share.deleteByUid(uid); + #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; + } - // res.json({ $: 'api:status-report', status: 'success' }); - // }; + #query(req: Request): Record { + return (req.query ?? {}) as Record; + } - // // -- POST /sharelink/request ------------------------------------- - // // Auth required — notify the issuer that someone is requesting access. + #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; + } - // #request = async (req: Request, res: Response): Promise => { - // const uid = req.body?.uid; - // if (typeof uid !== 'string') throw new HttpError(400, 'Missing `uid`'); + #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; + } - // 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 ?? {}, - // }, - // }); - - // // 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 }, - // ); - - // // 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) }); - // } - // } - - // const allOk = recipientResults.every( - // (r: unknown) => (r as Record).status === 'success', - // ); - // const anyOk = recipientResults.some( - // (r: unknown) => (r as Record).status === 'success', - // ); - - // res.json({ - // $: 'api:share', - // $version: 'v0.0.0', - // status: allOk ? 'success' : anyOk ? 'mixed' : 'aborted', - // recipients: recipientResults, - // ...(dryRun ? { dry_run: true } : {}), - // }); - // }; - - // // -- Helpers ------------------------------------------------------ - - // /** - // * 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; - // }> = []; - - // for (const share of shares) { - // if (!share || typeof share !== 'object') continue; - // const s = share as Record; - - // 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` }); - // } - // } - // } - - // 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 6c3235022..dc1306062 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 1168b795b..34af18171 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 f1e42e022..0b9007c42 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 58d5fba40..d78401d49 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 000000000..814e001cf --- /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 000000000..b821ea7e0 --- /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 eef0a94f1..fa75ce870 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 () => { @@ -2385,6 +2463,53 @@ describe('FSService move', () => { expect(await entryAt(user, '/Desktop/moveddir/in.txt')).not.toBeNull(); }); + it('refuses a name that would steer the move out of the destination', async () => { + const entry = await writeFile( + user, + `${user.home}/Documents/steer.txt`, + 'x', + ); + const destination = (await entryAt(user, '/Desktop'))!; + + for (const newName of ['../Documents/pwned.txt', 'a/b.txt', '..']) { + const error = await caught(() => + fs.move(user.userId, { + source: entry, + destinationParent: destination, + newName, + }), + ); + expect(error.statusCode).toBe(400); + } + expect(await entryAt(user, '/Documents/steer.txt')).not.toBeNull(); + }); + + it('deletes descendants a different user owns', async () => { + // Rows predating the one-owner-per-subtree invariant: an app writing + // into another user's AppData used to stamp itself as the owner. The + // walk has to reach them, or the parent's deletion orphans them. + const other = await makeUser(); + const dir = await fs.mkdir(user.userId, { + path: `${user.home}/Documents/mixed`, + }); + await writeFile(user, `${user.home}/Documents/mixed/mine.txt`, 'x'); + const foreign = await writeFile( + user, + `${user.home}/Documents/mixed/theirs.txt`, + 'x', + ); + await server.clients.db.write( + 'UPDATE `fsentries` SET `user_id` = ? WHERE `uuid` = ?', + [other.userId, foreign.uuid], + ); + + await fs.remove(user.userId, { entry: dir, recursive: true }); + + expect( + await server.stores.fsEntry.getEntryByUuid(foreign.uuid), + ).toBeNull(); + }); + it('refuses to move another user’s entry', async () => { const other = await makeUser(); const foreign = await writeFile( @@ -2707,7 +2832,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 +2871,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 +3467,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 +3623,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 +3652,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 8ef917570..4b83c9912 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,15 +3389,13 @@ 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 { - if (newName.includes('/')) - throw new HttpError(400, 'Name cannot contain a slash', { - legacyCode: 'bad_request', - }); - if (newName.trim().length === 0) - throw new HttpError(400, 'Name cannot be empty', { - legacyCode: 'bad_request', - }); + async rename( + userId: number, + entry: FSEntry, + newName: string, + ): Promise { + await this.#assertCanRename(entry, userId); + this.#assertUsableName(newName); if (entry.name === newName) return entry; await this.#assertCrossAppDeleteAllowed(entry.path); @@ -3279,7 +3463,6 @@ export class FSService extends PuterService { } } const created = await this.stores.fsEntry.createNonFileEntry({ - userId, parent: input.parent, name, kind: 'shortcut', @@ -3291,13 +3474,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 +3507,92 @@ export class FSService extends PuterService { } } + /** + * A name has to be one path segment. Rename, move and copy all splice the + * caller's name straight into the parent's path, so a slash or a `.`/`..` + * segment would name a row outside the directory the caller was authorized + * against — and `move` into the owner's Trash is authorized without a write + * check on the destination at all. + */ + #assertUsableName(name: string): void { + if (typeof name !== 'string' || name.trim().length === 0) + throw new HttpError(400, 'Name cannot be empty', { + legacyCode: 'bad_request', + }); + if (name.includes('/')) + throw new HttpError(400, 'Name cannot contain a slash', { + legacyCode: 'bad_request', + }); + if (name === '.' || name === '..') + throw new HttpError(400, 'Name cannot be `.` or `..`', { + legacyCode: 'bad_request', + }); + } + + /** + * 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 +3611,10 @@ 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.path, ); if (descendants.length > 0 && !input.recursive) { @@ -3584,10 +3836,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' }, ); } @@ -3606,6 +3863,7 @@ export class FSService extends PuterService { } let name = input.newName ?? source.name; + this.#assertUsableName(name); const targetPath = destinationParent.path === '/' ? `/${name}` @@ -3647,9 +3905,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 +3926,10 @@ export class FSService extends PuterService { if (source.isDir && source.path !== finalPath) { await this.stores.fsEntry.updatePathPrefixForUser( - userId, + source.userId, source.path, finalPath, + newOwnerId, ); } @@ -3670,6 +3940,7 @@ export class FSService extends PuterService { node: updated, fromPath: source.path, toPath: finalPath, + fromUserId: source.userId, }, {}, ); @@ -3716,6 +3987,7 @@ export class FSService extends PuterService { } let name = input.newName ?? source.name; + this.#assertUsableName(name); const targetPath = destinationParent.path === '/' ? `/${name}` @@ -3727,12 +3999,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 +4052,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', @@ -3787,7 +4062,6 @@ export class FSService extends PuterService { }); const descendants = await this.stores.fsEntry.listDescendantsByPath( - source.userId, source.path, ); // Sort shallow-first so parents exist before children. @@ -3807,7 +4081,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 +4116,6 @@ export class FSService extends PuterService { ): Promise { if (source.isSymlink) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'symlink', @@ -3854,7 +4126,6 @@ export class FSService extends PuterService { } if (source.isShortcut) { return this.stores.fsEntry.createNonFileEntry({ - userId, parent: destinationParent, name: newName, kind: 'shortcut', @@ -3870,7 +4141,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 e07c036b5..182890724 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 978cdceb7..be16fa6ec 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 59614f128..7d866d0c9 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 43caf0cb8..47cb4d1a9 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 000000000..f555d890a --- /dev/null +++ b/src/backend/services/fs/sharePathMask.test.ts @@ -0,0 +1,228 @@ +/* + * 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, + maskPathForRequest, + maskerFor, + parseMaskedSharePath, + resolveSharePath, +} from './sharePathMask'; +import { runWithContext } from '../../core/context'; +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'); + }); +}); + +describe('maskerFor', () => { + const run = (fn: () => T): T => runWithContext({}, fn); + + it('adopts the actor when the first caller had none', () => { + run(() => { + // Whoever touches the masker first fixes its actor. A caller that + // runs before the request context knows who is acting would + // otherwise pin `undefined` for the whole request — and `mask()` + // then publishes every path unmasked, which is the failure that + // matters. + const blind = maskerFor(undefined) as SharePathMasker; + blind.learn('/alice/Documents/Work', `/alice/${UID}/Work`); + expect(blind.actorUserId).toBeUndefined(); + + const seeing = maskerFor(actorFor('bob', 2)) as SharePathMasker; + expect(seeing.actorUserId).toBe(2); + // The roots it already proved carry over. + expect( + seeing.mask({ + path: '/alice/Documents/Work/f.txt', + uuid: 'child', + name: 'f.txt', + userId: 1, + }), + ).toBe(`/alice/${UID}/Work/f.txt`); + }); + }); + + it('starts over rather than lend one actor’s roots to another', () => { + run(() => { + const first = maskerFor(actorFor('bob', 2)) as SharePathMasker; + first.learn('/alice/Documents/Work', `/alice/${UID}/Work`); + + const second = maskerFor(actorFor('carol', 3)) as SharePathMasker; + expect(second.actorUserId).toBe(3); + expect(second.learnedRoots()).toEqual([]); + }); + }); + + it('masks a bare path against the roots this request proved', () => { + run(() => { + const masker = maskerFor(actorFor('bob', 2)) as SharePathMasker; + masker.learn('/alice/Documents/Work', `/alice/${UID}/Work`); + // A pending upload has no row yet, so there is no entry to mask. + expect(maskPathForRequest('/alice/Documents/Work/new.txt')).toBe( + `/alice/${UID}/Work/new.txt`, + ); + // Nothing proved about it — the caller named it themselves. + expect(maskPathForRequest('/bob/Desktop/mine.txt')).toBe( + '/bob/Desktop/mine.txt', + ); + }); + }); +}); diff --git a/src/backend/services/fs/sharePathMask.ts b/src/backend/services/fs/sharePathMask.ts new file mode 100644 index 000000000..d727c4c10 --- /dev/null +++ b/src/backend/services/fs/sharePathMask.ts @@ -0,0 +1,264 @@ +/* + * 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; + } + + /** The actor this masker was built for; `undefined` means "unknown". */ + get actorUserId(): number | undefined { + return this.#actorUserId; + } + + learn(realPrefix: string, maskedPrefix: string): void { + this.#roots.set(realPrefix, maskedPrefix); + } + + /** The roots proved so far, so a rebuild doesn't have to reprove them. */ + learnedRoots(): ReadonlyArray { + return [...this.#roots.entries()]; + } + + /** 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; + } + + const bestReal = this.#longestLearnedRoot(entry.path); + 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 path to publish for a location that has no row behind it yet — a + * pending upload, or the progress meta for one. + * + * Only the roots this request already proved are applied. There is no + * self-mask fallback because there is no uuid to build one from, and none + * is needed: reaching a foreign path without going through a masked one + * means the caller named the owner's real path themselves. + */ + maskPath(path: string): string { + const bestReal = this.#longestLearnedRoot(path); + if (bestReal === null) return path; + return ( + (this.#roots.get(bestReal) as string) + path.slice(bestReal.length) + ); + } + + /** The deepest learned root `path` sits at or under. */ + #longestLearnedRoot(path: string): string | null { + let bestReal: string | null = null; + for (const real of this.#roots.keys()) { + if (path !== real && !path.startsWith(`${real}/`)) continue; + if (bestReal === null || real.length > bestReal.length) { + bestReal = real; + } + } + return bestReal; + } +} + +/** + * 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) { + const actorUserId = actor?.user?.id; + // First caller wins on the actor, and getting that wrong fails open: + // a masker built before the request context knew who was acting has no + // actor id, and `mask()` then publishes every path unmasked. Adopt the + // first actor that turns up, and rebuild outright if a different one + // does — one request's learned roots are not another actor's to use. + if (actorUserId === undefined || actorUserId === existing.actorUserId) { + return existing; + } + if (existing.actorUserId === undefined) { + const adopted = new SharePathMasker(actorUserId); + for (const [real, masked] of existing.learnedRoots()) { + adopted.learn(real, masked); + } + Context.set(MASKER_KEY, adopted); + return adopted; + } + const replacement = new SharePathMasker(actorUserId); + Context.set(MASKER_KEY, replacement); + return replacement; + } + const masker = new SharePathMasker(actor?.user?.id); + Context.set(MASKER_KEY, masker); + return masker; +} + +/** + * Mask a bare path for the current request — for locations with no row behind + * them, where {@link maskEntryPath} has no entry to work from. + */ +export function maskPathForRequest(path: string): string { + const masker = maskerFor(Context.get('actor')); + return masker ? masker.maskPath(path) : path; +} + +/** 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 2a38744b7..9be7c0fee 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 d6e033163..1089d35c0 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 762f71736..f63ef448f 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 1868b37a8..2e8999dc9 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 000000000..f12702138 --- /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 000000000..50f75c0ef --- /dev/null +++ b/src/backend/services/share/ShareService.test.ts @@ -0,0 +1,1597 @@ +/* + * 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); + }); + + // ── review fixes ──────────────────────────────────────────────── + + it('drops a listing whose grant was withdrawn outside the index', 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 before = await server.services.share.listSharedWithMe( + recipient.actor, + ); + expect(before.items.map((i) => i.entryUid)).toContain(file.uuid); + + // Straight at the permission layer, the way `/auth/revoke-user-user` + // does — the share row survives, so the index alone would keep + // publishing the entry's name, size and a signed thumbnail URL. + await server.services.permission.revokeUserUserPermission( + owner.actor, + recipient.user.username!, + `fs:${file.uuid}:read`, + ); + expect(await canRead(recipient.actor, file.path)).toBe(false); + + const after = await server.services.share.listSharedWithMe( + recipient.actor, + ); + expect(after.items.map((i) => i.entryUid)).not.toContain(file.uuid); + }); + + it('lets a recipient leave a share that was never indexed', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + // A grant with no share row behind it — how anything predating the + // index looks. + await server.services.acl.setUserUser( + owner.actor, + recipient.actor, + { + path: file.path, + resolveAncestors: () => + server.services.fs.getAncestorChain(file.path), + }, + 'read', + ); + expect(await canRead(recipient.actor, file.path)).toBe(true); + + const result = await unshare(recipient.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + }); + + expect(result.revoked).toBeGreaterThan(0); + expect(await canRead(recipient.actor, file.path)).toBe(false); + }); + + it('refuses to let a manage delegate hand out manage', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const { dir } = await makeDirWithFile(owner.user); + + await share(owner.actor, { + uid: dir.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + + // Write is theirs to pass on; manage is not. + await expect( + share(delegate.actor, { + uid: dir.uuid, + recipient: { email: third.email }, + mode: 'write', + }), + ).resolves.toMatchObject({ mode: 'write' }); + + await expect( + share(delegate.actor, { + uid: dir.uuid, + recipient: { email: third.email }, + mode: 'manage', + }), + ).rejects.toMatchObject({ statusCode: expect.any(Number) }); + }); + + it('retires a share when the item changes owner', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + const newOwner = await makeUser(); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + expect(await canRead(recipient.actor, file.path)).toBe(true); + + // What a move into someone else's tree does to the row. + await server.stores.fsEntry.updateEntry(file.uuid, { + userId: newOwner.user.id, + }); + const moved = await server.stores.fsEntry.getEntryByUuid(file.uuid); + await server.services.share.onEntryOwnerChanged(moved!); + + expect(await canRead(recipient.actor, file.path)).toBe(false); + expect((await server.stores.share.listByFsentry(file.id)).length).toBe( + 0, + ); + }); + + it('retires a whole burst of deletions in one flush', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const files = []; + for (let i = 0; i < 5; i++) { + const file = await makeFile(owner.user); + files.push(file); + await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + } + + const removed = await server.services.share.onEntryDeleted( + files.map((f) => f.uuid), + ); + expect( + new Set( + removed.map( + (row) => + row.permission.replace(/^manage:/u, '').split(':')[1], + ), + ), + ).toEqual(new Set(files.map((f) => f.uuid))); + + for (const file of files) { + expect(await canRead(recipient.actor, file.path)).toBe(false); + } + }); +}); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts new file mode 100644 index 000000000..2fcda9f25 --- /dev/null +++ b/src/backend/services/share/ShareService.ts @@ -0,0 +1,1322 @@ +/* + * 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 { MANAGE_PERM_PREFIX } from '../permission/consts'; +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}`, +]; + +/** + * The permission a share of `mode` actually grants — which is what authority to + * issue that share has to be measured against. + */ +export const entryPermissionForMode = ( + uuid: string, + mode: AclMode | string, +): string => + mode === MANAGE_PERM_PREFIX + ? `${MANAGE_PERM_PREFIX}:fs:${uuid}` + : `fs:${uuid}:${mode}`; + +/** + * The entry a grant names, read back out of the permission text. + * + * `fs:[:mode]` and `manage:fs:` are the only two shapes this + * service writes; anything else belongs to another domain and is not ours to + * interpret. + */ +export const uuidFromEntryPermission = (permission: string): string | null => { + const parts = permission.split(':'); + const fsAt = parts[0] === MANAGE_PERM_PREFIX ? 1 : 0; + if (parts[fsAt] !== 'fs') return null; + return parts[fsAt + 1] || null; +}; + +/** + * How many entries' grants one retire query covers. Each entry contributes two + * indexed range scans (`fs:` and `manage:fs:`), so this bounds the + * work per statement while still collapsing a deleted subtree into a few round + * trips instead of one per descendant. + */ +const RETIRE_CHUNK_SIZE = 100; + +/** Shares one user may create per UTC day, absent a config override. */ +export const DEFAULT_DAILY_SHARE_LIMIT = 200; + +/** + * 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; + + /** Entries awaiting the next retire flush, deduped by uuid. */ + #pendingRetire = new Map(); + /** The in-flight flush, shared by everything buffered for it. */ + #retireFlush: Promise | null = null; + + /** + * FS mutations only notify the owner, leaving a recipient's open window + * stale. Handled here rather than per controller so the audience logic + * lives in one place, and over the event bus because fs is constructed + * first and cannot depend on this service. + */ + override onServerStart(): void { + this.clients.event.on('fs.remove.node', (_key, data) => { + const entry = (data as { node?: FSEntry })?.node; + if (!entry?.uuid) return; + // Returned so an `emitAndWait` caller can observe the cleanup; the + // FS path uses plain `emit`, where it stays best-effort. + return this.#scheduleRetire(entry).catch((err) => { + console.warn( + '[ShareService] failed to retire grants for a deleted entry:', + entry.uuid, + err, + ); + }); + }); + + this.clients.event.on('fs.move.node', (_key, data) => { + const { node, fromPath, fromUserId } = (data ?? {}) as { + node?: FSEntry; + fromPath?: string; + fromUserId?: number; + }; + if (!node?.uuid) return; + const notify = 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. + }); + + // Grants are keyed on uuid, so they would otherwise follow the + // entry into its new owner's tree — leaving the new owner with + // recipients they never agreed to. Awaited alongside the notify so + // an `emitAndWait` caller sees both. + if (typeof fromUserId !== 'number' || fromUserId === node.userId) { + return notify; + } + return Promise.all([ + notify, + this.onEntryOwnerChanged(node).catch((err: unknown): void => { + console.warn( + '[ShareService] failed to retire grants after an ownership change:', + node.uuid, + err, + ); + }), + ]).then((): void => undefined); + }); + + 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. + }); + }); + } + + /** + * Buffer a removed entry for the next retire flush, and hand back the + * promise for the flush that will carry it. + * + * `remove()` emits one `fs.remove.node` per descendant in a synchronous + * loop, so deleting a directory arrives here as a burst. Retiring each one + * on its own would put one permission lookup per descendant on the delete + * path; coalescing turns a whole subtree into a handful of queries. The + * buffer is swapped out before the flush runs, so entries removed while it + * is in flight land in the next one rather than being lost. + */ + #scheduleRetire(entry: FSEntry): Promise { + this.#pendingRetire.set(entry.uuid, entry); + this.#retireFlush ??= new Promise((resolve, reject) => { + setImmediate(() => { + const batch = this.#pendingRetire; + this.#pendingRetire = new Map(); + this.#retireFlush = null; + this.#flushRetire([...batch.values()]).then(resolve, reject); + }); + }); + return this.#retireFlush; + } + + /** + * Retire the grants, then tell the recipients. The revoke reports exactly + * who lost access, which the index can no longer answer — its rows cascade + * away with the fsentry — and it reports it per permission, so the holders + * are attributed back to the entry each grant named. + */ + async #flushRetire(entries: FSEntry[]): Promise { + const ownerOf = new Map(entries.map((e) => [e.uuid, e])); + + for (let i = 0; i < entries.length; i += RETIRE_CHUNK_SIZE) { + const chunk = entries.slice(i, i + RETIRE_CHUNK_SIZE); + const removed = await this.onEntryDeleted( + chunk.map((entry) => entry.uuid), + ); + + const holdersByEntry = new Map>(); + for (const row of removed) { + const uuid = uuidFromEntryPermission(row.permission); + const entry = uuid ? ownerOf.get(uuid) : undefined; + const holderId = Number(row.holder_user_id); + if (!entry || !Number.isFinite(holderId)) continue; + if (holderId === entry.userId) continue; + const holders = + holdersByEntry.get(entry.uuid) ?? new Set(); + holders.add(holderId); + holdersByEntry.set(entry.uuid, holders); + } + + for (const [uuid, holders] of holdersByEntry) { + const entry = ownerOf.get(uuid) as FSEntry; + await this.#emitGui( + 'outer.gui.item.removed', + [...holders], + holderPayload(entry), + ); + } + } + } + + 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. + * + * This runs behind every file write, so it stays on indexed access paths + * only: the ancestors are resolved to row ids through the fsentry store + * (cached, and by path, which is indexed), and the share lookup is then a + * single `fsentry_id IN (...)` against `idx_share_fsentry`. Asking the + * share table to join fsentries and match `path IN (...)` instead put an + * un-indexable OR on the write path. + */ + async #sharesReaching( + entry: FSEntry, + ): Promise> { + const ancestorPaths: string[] = []; + for ( + let cursor = pathPosix.dirname(entry.path); + cursor !== '/' && cursor !== '.'; + cursor = pathPosix.dirname(cursor) + ) { + ancestorPaths.push(cursor); + } + const ancestors = + ancestorPaths.length > 0 + ? await this.stores.fsEntry.getEntriesByPaths(ancestorPaths) + : new Map(); + const ids = [ + entry.id, + ...[...ancestors.values()].map((node) => node.id), + ].filter((id): id is number => typeof id === 'number'); + return this.stores.share.listReaching(ids); + } + + 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); + } + + /** Everyone currently granting `holderId` something on this node. */ + async #issuersGranting( + entry: FSEntry, + holderId: number, + ): Promise { + const rows = await this.stores.permission.readLinkedUserUserPerms( + holderId, + entryPermissions(entry.uuid), + ); + return [ + ...new Set(rows.map((row) => Number(row.issuer_user_id))), + ].filter((id) => Number.isFinite(id)); + } + + /** + * Of `entries`, the uuids `holderId` still holds a live grant on. + * + * The index is not proof of access: a grant can be withdrawn or downgraded + * by a path that never touches a share row (`/auth/revoke-user-user`, or an + * ACL mode change), and this listing publishes name, size and a signed + * thumbnail URL — so it has to be checked against the grants themselves. + * + * Two batched reads for the whole page, both keyed on the holder: the + * linked rows are indexed on `holder_user_id`, and the flat view is a + * multi-get. An ACL walk per row would be the same answer at hundreds of + * times the cost. + */ + async #liveGrants( + holderId: number, + entries: FSEntry[], + ): Promise> { + if (entries.length === 0) return new Set(); + const wanted = entries.flatMap((entry) => entryPermissions(entry.uuid)); + const [linked, flat] = await Promise.all([ + this.stores.permission.readLinkedUserUserPerms(holderId, wanted), + this.stores.permission.getFlatUserPerms(holderId, wanted), + ]); + + const live = new Set(); + for (const row of linked) { + const uuid = uuidFromEntryPermission(row.permission); + if (uuid) live.add(uuid); + } + for (let i = 0; i < wanted.length; i++) { + const value = flat[i]; + if (!value || value.deleted) continue; + const uuid = uuidFromEntryPermission(wanted[i]); + if (uuid) live.add(uuid); + } + // An owner listing something shared *to* them can't happen, but a + // recipient who has since become the owner (a move handed the tree + // over) holds it outright and has no grant row. + for (const entry of entries) { + if (entry.userId === holderId) live.add(entry.uuid); + } + return live; + } + + /** + * Withdraw a recipient's access. An owner may clear any issuer's share of + * their node; anyone else may only clear the ones they issued. + */ + async unshare( + actor: Actor, + input: ShareTarget & { recipient: ShareRecipient }, + ): Promise<{ revoked: number }> { + const issuerId = this.#requireUserId(actor); + const [entry, 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 live grants when no index row exists — a grant may + // predate the index, and a revoke must still work. Reading them is what + // makes "leave this share" work at all: the issuer there is whoever + // shared with you, never yourself, so defaulting to the caller would + // scope the delete to a row that cannot exist and report nothing + // revoked. Indexed on holder_user_id, and only reached when the index + // came up empty. + let issuers: number[] = + rows.length > 0 + ? [ + ...new Set( + rows.map( + (row: { issuer_user_id: number }) => + row.issuer_user_id, + ), + ), + ] + : await this.#issuersGranting(entry, holder.id); + if (!isOwner && !isLeaving) { + issuers = issuers.filter((issuer) => issuer === issuerId); + } + if (issuers.length === 0) issuers = [issuerId]; + + // 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(entryUids: string | string[]): Promise< + Array<{ + holder_user_id: number; + issuer_user_id: number; + permission: string; + }> + > { + const uids = Array.isArray(entryUids) ? entryUids : [entryUids]; + if (uids.length === 0) return []; + // Two prefixes per entry, because `manage:fs:` 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( + uids.flatMap((uid) => [`fs:${uid}`, `manage:fs:${uid}`]), + ); + + // Retiring the rows is not enough: a holder's cached scan still answers + // "allowed" until it ages out. The revoke path bumps for this reason, + // and a delete has to as well or access outlives the file. + const holderIds = [ + ...new Set(removed.map((row) => Number(row.holder_user_id))), + ].filter((id) => Number.isFinite(id)); + const holders = await this.stores.user.getByIds(holderIds); + await this.stores.permission.bumpCacheGenerations( + [...holders.values()] + .filter((user) => user.uuid) + .map((user) => `user:${user.uuid}`), + ); + + return removed; + } + + /** + * Retire the shares on a node that has just changed owner. + * + * A grant names a uuid, so it would otherwise ride along into the new + * owner's tree and leave them with recipients they never agreed to. The + * fsentry survives the move, so nothing cascades — the rows have to go + * explicitly. + * + * Scoped by the share index rather than by walking the subtree: the work is + * bounded by how many shares exist under the node (usually none), not by + * how many files it holds, and the recursive walk rides `parent_id`. + */ + async onEntryOwnerChanged(entry: FSEntry): Promise { + const rows = entry.isDir + ? await this.stores.share.listByFsentrySubtree(entry.id) + : await this.stores.share.listByFsentry(entry.id); + const fsentryIds = [ + ...new Set([ + entry.id, + ...rows.map((row: { fsentry_id: number }) => + Number(row.fsentry_id), + ), + ]), + ].filter((id) => Number.isFinite(id)); + + const nodes = await this.stores.fsEntry.getEntriesByIds(fsentryIds); + const uuids = [...nodes.values()].map((node) => node.uuid); + if (uuids.length > 0) await this.onEntryDeleted(uuids); + await this.stores.share.deleteByFsentryIds(fsentryIds); + } + + // -- Reads -------------------------------------------------------- + + /** + * What has been shared with `actor`, newest page first by id. Entries are + * hydrated in one batch; rows whose entry is gone, or which resolve into + * the owner's trash, are dropped — the share survives a trashing so a + * restore is lossless, it just shouldn't be listed. + */ + async listSharedWithMe( + actor: Actor, + opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {}, + ): Promise<{ + items: ResolvedShare[]; + cursor?: string; + total?: number; + }> { + const holderId = this.#requireUserId(actor); + const page = await this.stores.share.listByHolder(holderId, { + limit: opts.limit, + cursor: opts.cursor, + }); + + const entries = await this.stores.fsEntry.getEntriesByIds( + page.items.map((row: { fsentry_id: number }) => row.fsentry_id), + ); + const issuers = await this.stores.user.getByIds([ + ...page.items.map((row: { issuer_user_id: number }) => + Number(row.issuer_user_id), + ), + ...[...entries.values()].map((entry) => entry.userId), + ]); + + // Everything listed here is a shared root, so record them all: entries + // reached by opening one keep the same masked root and stay navigable. + await learnShareRoots([...entries.values()], actor); + + // Two independent bounds. The grant has to still be there — the index + // row outlives it if access was withdrawn any other way. And an app + // sees only the part of that its user handed to the app, where a + // session sees all of it. + const [live, reachable] = await Promise.all([ + this.#liveGrants(holderId, [...entries.values()]), + this.#reachableBy(actor, [...entries.values()]), + ]); + + const items: ResolvedShare[] = []; + for (const row of page.items) { + const entry = entries.get(Number(row.fsentry_id)); + if (!entry || this.#isTrashed(entry)) continue; + if (!live.has(entry.uuid)) continue; + if (!reachable.has(entry.uuid)) continue; + 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. + // + // Asked about the permission actually being handed out, not a fixed + // `read`. Handing out `manage` needs authority over `manage`, which + // only the owner has — `grantUserUserPermission` enforces that too, but + // two layers down and with a rawer error, so a caller who cannot do it + // should be turned away here. + const allowed = await this.services.permission.canManagePermission( + userRelatedActor(actor), + entryPermissionForMode(entry.uuid, mode), + ); + // Reach is the second, independent bound: a credential may only hand + // out access it holds itself. For a session that is a no-op; for an app + // it is what keeps sharing to its own AppData and the files it was + // given, rather than everything its user owns. + if (allowed && (await this.#hasOwnReach(actor, entry, mode))) return; + + 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.test.ts b/src/backend/stores/fs/FSEntryStore.test.ts index 007bc684b..614e7ddee 100644 --- a/src/backend/stores/fs/FSEntryStore.test.ts +++ b/src/backend/stores/fs/FSEntryStore.test.ts @@ -1127,10 +1127,7 @@ describe('FSEntryStore listing and pagination', () => { }); it('lists and counts descendants, refusing to walk from root', async () => { - const descendants = await store.listDescendantsByPath( - user.userId, - parent.path, - ); + const descendants = await store.listDescendantsByPath(parent.path); expect(descendants.map((entry) => entry.name).sort()).toEqual([ 'a.txt', 'b.txt', @@ -1142,9 +1139,7 @@ describe('FSEntryStore listing and pagination', () => { store.countDescendantsByPath(user.userId, parent.path), ).resolves.toBe(5); - const rootList = await caught(() => - store.listDescendantsByPath(user.userId, '/'), - ); + const rootList = await caught(() => store.listDescendantsByPath('/')); expect(rootList.statusCode).toBe(400); await expect( store.countDescendantsByPath(user.userId, '/'), diff --git a/src/backend/stores/fs/FSEntryStore.ts b/src/backend/stores/fs/FSEntryStore.ts index d56a625ae..15196881a 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, @@ -2485,7 +2489,8 @@ export class FSEntryStore extends PuterStore { } = {}, ): Promise<{ entries: FSEntry[]; cursor?: string }> { const payload = decodeCursor(options.cursor) as - { v: unknown; id: number; s?: string; o?: string } | undefined; + | { v: unknown; id: number; s?: string; o?: string } + | undefined; const requestedSort = options.sortBy ?? null; const requestedOrder = options.sortOrder ?? null; @@ -2600,11 +2605,8 @@ export class FSEntryStore extends PuterStore { // All descendants of a directory path (recursive). Paths in fsentries are // absolute and don't carry a trailing slash, so the prefix pattern is - // `${prefix}/%`. Scoped by user_id to keep the index tight. - async listDescendantsByPath( - userId: number, - pathPrefix: string, - ): Promise { + // `${prefix}/%`, served by `idx_fsentries_path`. + async listDescendantsByPath(pathPrefix: string): Promise { const normalizedPrefix = this.#normalizePath(pathPrefix); if (normalizedPrefix === '/') { // Refuse to list all user entries this way — caller must mean something else. @@ -2613,9 +2615,15 @@ export class FSEntryStore extends PuterStore { }); } const likePattern = `${this.#escapeLikePattern(normalizedPrefix)}/%`; + // Everything under the prefix, whoever owns it. A subtree is meant to + // have one owner, but rows written before that was enforced don't, and + // `AND user_id = ?` would leave those behind when the caller deletes + // the directory above them — orphaned rows, and their S3 objects with + // them. `idx_fsentries_path` leads on `path`, so the access path is + // unchanged. const rows = (await this.clients.db.read( - `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE user_id = ? AND path LIKE ? ESCAPE '!' ORDER BY path ASC`, - [userId, likePattern], + `SELECT ${this.#selectFsentriesColumns()} FROM fsentries WHERE path LIKE ? ESCAPE '!' ORDER BY path ASC`, + [likePattern], )) as unknown as FSEntryRow[]; return rows.map((row) => this.#mapFSEntryRow(row)); } @@ -2668,7 +2676,8 @@ export class FSEntryStore extends PuterStore { const limit = normalizeLimit(options.limit, { cap: 10_000 }) ?? 1000; const payload = decodeCursor(options.cursor) as - { p: string } | undefined; + | { p: string } + | undefined; const seek = payload ? 'AND path > ?' : ''; const params: unknown[] = payload ? [userId, likePattern, maxSlashes, payload.p, limit + 1] @@ -2776,6 +2785,7 @@ export class FSEntryStore extends PuterStore { patch: { name?: string; path?: string; + userId?: number; parentId?: number | null; parentUid?: string | null; thumbnail?: string | null; @@ -2800,6 +2810,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 +2958,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 +2986,26 @@ export class FSEntryStore extends PuterStore { postgres: '? || SUBSTR(path, ?)', otherwise: 'CONCAT(?, SUBSTR(path, ?))', }); + const reowning = newUserId !== undefined && newUserId !== userId; + // Scoped by path alone. A subtree is meant to have one owner, but rows + // written before that was enforced don't — an app writing into another + // user's AppData used to stamp itself as the owner — and an `AND + // user_id = ?` would walk straight past them, leaving descendants + // pointing at a path their parent no longer has. `idx_fsentries_path` + // leads on `path`, so this is the same index range either way. 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], + WHERE path LIKE ? ESCAPE '!'`, + [ + normalizedNew, + oldPrefixLen + 1, + ...(reowning ? [newUserId] : []), + now, + 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 a9fe1d158..72fa59222 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,169 @@ 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( + holder.id, + issuer.id, + 'fs:u:read', + {}, + ); + + 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', + 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( @@ -716,26 +1009,26 @@ 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, + ), + ).toBe(true); expect( - await store.readLinkedUserUserPerms(holder.id, ['fs:u:read']), - ).toEqual([]); - }); - - it('returns nothing for an empty permission list', async () => { - const holder = await makeUser(); - expect(await store.readLinkedUserUserPerms(holder.id, [])).toEqual( - [], - ); + 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 2f7326204..0ed4e883d 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 182505e01..0a9b12873 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. * - * 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. + * 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. + * + * Permissions remain the source of truth for access. This is the index. */ export class ShareStore extends PuterStore { // -- Reads -------------------------------------------------------- @@ -57,6 +66,126 @@ 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 any of `fsentryIds` — a node plus its ancestors, which + * the caller has already resolved to row ids. + * + * This sits behind every file-write event, so it must stay on + * `idx_share_fsentry`: a plain `IN` does, whereas joining `fsentries` and + * OR-ing a path match does not, and the optimizer falls back to a scan of + * `share`. + * + * @param {number[]} fsentryIds + */ + async listReaching(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)); + } + + 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 +204,146 @@ 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); + } + + /** + * Drop every active share on any of `fsentryIds`. Used when a node changes + * hands: the fsentry survives, so the delete cascade that normally retires + * its shares never fires. + * + * @param {number[]} fsentryIds + */ + async deleteByFsentryIds(fsentryIds) { + if (fsentryIds.length === 0) return 0; + const placeholders = fsentryIds.map(() => '?').join(', '); + const result = await this.clients.db.write( + `DELETE FROM \`share\` WHERE \`fsentry_id\` IN (${placeholders})`, + fsentryIds, + ); + return result?.affectedRows ?? result?.changes ?? 0; + } + async deleteByUid(uid) { const result = await this.clients.db.write( 'DELETE FROM `share` WHERE `uid` = ?', @@ -91,6 +360,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 c2342b277..7d0053859 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 eb6ca2752..869459f13 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 62dfece28..bfd270219 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 50ed08658..0fdf22466 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 68d505bb3..031b875fb 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 000000000..bceffc008 --- /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 000000000..85d12740b --- /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 000000000..b7ae7d565 --- /dev/null +++ b/src/docs/src/FS/share.md @@ -0,0 +1,152 @@ +--- +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. + +## What sharing does not promise + +Three things are worth knowing before you share something sensitive. + +**A signed URL outlives the share.** Anyone who can read a shared item can +mint a signed URL for it, and that URL is a bearer token: it works for whoever +holds it, signed in or not. Signatures over an item you do not own expire after +an hour, but withdrawing access does not invalidate one that has already been +issued. Treat an hour as the floor on how long a recipient can keep, or pass +on, what you gave them. + +**An app you have authorized can share on your behalf.** Sharing is done in +your name, so an app acting for you can share the items it can already reach — +its own AppData, and whatever you handed it — with anyone, and at any level it +holds itself. It cannot reach past that into the rest of your files. Shares an +app issued are marked with `issued_by_app` in +[`getShares()`](/FS/getShares/), so you can tell them apart from your own. + +**Moving an item into someone else's folder hands it over.** The folder's owner +becomes the item's owner, its bytes start counting against their storage rather +than yours, and any shares you had on it are withdrawn — they were yours to +give, and it is no longer yours. The same applies in reverse: files a recipient +creates inside a folder you shared belong to you and count against your +storage. + +## 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 000000000..815847367 --- /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 123dbf243..3bdad34b8 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 dc569ae6d..07c9ff016 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 9919100a3..033dc090c 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 713d9e2b8..e0a466c22 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 000000000..2b58f3c3d --- /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 0a3ca51d0..3a1554faf 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 c0724d13d..2c789de3e 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 fd02387ef..af15fcb18 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 000000000..2f8e8b4bc --- /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 a98b9f249..19edbb889 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 000000000..b9954cff7 --- /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 000000000..1368f3a80 --- /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 51956aef8..e3c6fff41 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 000000000..1d056f634 --- /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 000000000..53550af58 --- /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 000000000..f6e238d9c --- /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 000000000..e767e246c --- /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 3b318fcb4..017787add 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 000000000..393467847 --- /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 000000000..393467847 --- /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 f39473dbf..f0f4a5cfd 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 3523bf15e..5ce7665e6 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 504e7726e..3cee3cafa 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 459781592..278e551f6 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 000000000..7f72cdc92 --- /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 000000000..51bf2d5f3 --- /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 000000000..ea3b42f1a --- /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 000000000..fad25ad80 --- /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 000000000..73db094b3 --- /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 4c1324bf7..46aa8b83f 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/src/modules/perms/grants.js b/src/puter-js/src/modules/perms/grants.js index 8bee74894..2d3d8d4b7 100644 --- a/src/puter-js/src/modules/perms/grants.js +++ b/src/puter-js/src/modules/perms/grants.js @@ -7,6 +7,10 @@ import { req } from './lib/req.js'; /** * Grants a permission to another user. + * + * @deprecated Retired server-side — the endpoint now returns 501. Share files + * with {@link https://docs.puter.com/FS/share/ puter.fs.share()} instead, which + * records the share so it can be listed and revoked. * @this {PermsModule} * @param {string} username * @param {string} permission diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts index e3312b902..5eda206c2 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/perms.suite.ts b/src/puter-js/tests/api/suites/perms.suite.ts index d217b3b81..dc3bb46df 100644 --- a/src/puter-js/tests/api/suites/perms.suite.ts +++ b/src/puter-js/tests/api/suites/perms.suite.ts @@ -16,39 +16,36 @@ const readAsOther = (t: TestContext, path: string) => ); export default suite('perms', { - 'grantUser lets another user read a file': async (t) => { - const path = `${home(t)}/perms-suite-shared.txt`; - await t.puter.fs.write(path, 'shared content'); - - const before = await readAsOther(t, path); - t.assert.ok( - before.status !== 200, - `other user should not read before grant (got ${before.status})`, - ); - - const granted = await t.puter.perms.grantUser( + 'grantUser is retired in favour of fs.share': async (t) => { + // Direct user-to-user grants left no share row, so the owner could + // neither see nor revoke them. Cross-user access is covered end to end + // by the sharing suite. + const res = await t.puter.perms.grantUser( t.env.users.other.username, - `fs:${path}:read`, + `fs:${home(t)}/perms-suite-shared.txt:read`, ); - t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`); - - const after = await readAsOther(t, path); - t.assert.equal(after.status, 200); - t.assert.equal(await after.text(), 'shared content'); + t.assert.ok(res.error, 'grantUser should be refused'); + t.assert.equal(res.code, 'not_implemented'); }, - 'revokeUser takes a granted permission away': async (t) => { + 'revokeUser takes a shared permission away': async (t) => { + // Revoking still works: it is how access granted before `grantUser` + // was retired, or through a share, gets taken back at the permission + // layer. const path = `${home(t)}/perms-suite-revoked.txt`; await t.puter.fs.write(path, 'soon private again'); - const permission = `fs:${path}:read`; - await t.puter.perms.grantUser(t.env.users.other.username, permission); + await t.puter.fs.share({ + path, + recipient: t.env.users.other.username, + mode: 'read', + }); const whileGranted = await readAsOther(t, path); t.assert.equal(whileGranted.status, 200); const revoked = await t.puter.perms.revokeUser( t.env.users.other.username, - permission, + `fs:${path}:read`, ); t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); @@ -59,14 +56,6 @@ export default suite('perms', { ); }, - 'grantUser to an unknown user reports an error': async (t) => { - const res = await t.puter.perms.grantUser( - 'perms-suite-no-such-user', - `fs:${home(t)}/whatever.txt:read`, - ); - t.assert.ok(res.error, 'granting to an unknown user should error'); - }, - 'createGroup returns a group uid': async (t) => { const created = await t.puter.perms.createGroup({ title: 'perms-suite-group', @@ -204,15 +193,6 @@ export default suite('perms', { t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); }, - 'granting a permission on a path that does not exist is refused': async (t) => { - const result = await t.puter.perms.grantUser( - t.env.users.other.username, - `fs:${home(t)}/perms-suite-never-created.txt:read`, - ); - t.assert.ok(result.error, 'granting on a missing entry should error'); - t.assert.equal(result.code, 'subject_does_not_exist'); - }, - // -- Groups -- 'createGroup defaults its metadata when called with no arguments': async (t) => { 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 000000000..664026b36 --- /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); + }, +});