mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-12 16:25:51 +00:00
✨ PUT-1412 File sharing backend api (#3553)
* 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:<uid> 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 /<user>/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:<uid>, 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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:<uid>` never satisfied `fs:<uid>: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/<uid>
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/<entry-uid>/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/<uid>` 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) <noreply@anthropic.com>
* 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 `/<owner>/<uid>/<name>`
already proves nothing above the share leaks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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/<appUid>` 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 <noreply@anthropic.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
co-authored by
Claude Fable 5
Daniel Salazar
parent
fc861da89f
commit
2c852bf6b3
+5
-1
@@ -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.
|
||||
|
||||
<div class="info"><strong>Need to share data across users?</strong> 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 <a href="/Workers/">Serverless Worker</a> — its code can act on the worker owner's resources, giving all users one shared backend.</div>
|
||||
<div class="info"><strong>Need to share data across users?</strong> 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 <a href="/FS/share/"><code>puter.fs.share()</code></a>. To keep centralized files that every user reads from and writes to, use a <a href="/Workers/">Serverless Worker</a> — its code can act on the worker owner's resources, giving all users one shared backend.</div>
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<strong class="example-title">See who can reach a file</strong>
|
||||
|
||||
```html;fs-getShares
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
await puter.fs.write('report.txt', 'Quarterly numbers');
|
||||
await puter.fs.share('report.txt', 'friend@example.com', 'read');
|
||||
|
||||
const shares = await puter.fs.getShares('report.txt');
|
||||
for (const share of shares) {
|
||||
puter.print(`${share.holder}: ${share.mode} (from ${share.issuer})<br>`);
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Withdraw everyone's access</strong>
|
||||
|
||||
```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
|
||||
@@ -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**, `/<owner>/<uid>/<name>`, where `<uid>` 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
|
||||
|
||||
<strong class="example-title">List everything shared with you</strong>
|
||||
|
||||
```html;fs-listShared
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
const page = await puter.fs.listShared({ includeTotal: true });
|
||||
puter.print(`${page.total} item(s) shared with you<br>`);
|
||||
for (const share of page.items) {
|
||||
puter.print(`${share.path} — ${share.mode} from ${share.issuer}<br>`);
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Page through every share</strong>
|
||||
|
||||
```js
|
||||
let cursor;
|
||||
const all = [];
|
||||
do {
|
||||
const page = await puter.fs.listShared({ limit: 50, cursor });
|
||||
all.push(...page.items);
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
```
|
||||
|
||||
<strong class="example-title">Open a file someone shared with you</strong>
|
||||
|
||||
```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
|
||||
@@ -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
|
||||
|
||||
<strong class="example-title">Share a file with another user</strong>
|
||||
|
||||
```html;fs-share
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
// (1) create a file
|
||||
await puter.fs.write('report.txt', 'Quarterly numbers');
|
||||
|
||||
// (2) share it, read-only
|
||||
const shares = await puter.fs.share('report.txt', 'friend@example.com');
|
||||
puter.print(`Shared with ${shares[0].holder} as ${shares[0].mode}<br>`);
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Let someone edit, and let someone else re-share</strong>
|
||||
|
||||
```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');
|
||||
```
|
||||
|
||||
<strong class="example-title">Share one item with several people</strong>
|
||||
|
||||
```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
|
||||
@@ -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
|
||||
|
||||
<strong class="example-title">Stop sharing a file</strong>
|
||||
|
||||
```html;fs-unshare
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
await puter.fs.write('report.txt', 'Quarterly numbers');
|
||||
await puter.fs.share('report.txt', 'friend@example.com');
|
||||
|
||||
const result = await puter.fs.unshare('report.txt', 'friend@example.com');
|
||||
puter.print(`Removed ${result.revoked} share(s)<br>`);
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
<strong class="example-title">Leave a share someone gave you</strong>
|
||||
|
||||
```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
|
||||
@@ -361,6 +361,38 @@ let sidebar = [
|
||||
source: '/FS/upload.md',
|
||||
path: '/FS/upload',
|
||||
},
|
||||
{
|
||||
title: '<code>share()</code>',
|
||||
page_title: '<code>puter.fs.share()</code>',
|
||||
title_tag: 'puter.fs.share()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/FS/share.md',
|
||||
path: '/FS/share',
|
||||
},
|
||||
{
|
||||
title: '<code>unshare()</code>',
|
||||
page_title: '<code>puter.fs.unshare()</code>',
|
||||
title_tag: 'puter.fs.unshare()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/FS/unshare.md',
|
||||
path: '/FS/unshare',
|
||||
},
|
||||
{
|
||||
title: '<code>listShared()</code>',
|
||||
page_title: '<code>puter.fs.listShared()</code>',
|
||||
title_tag: 'puter.fs.listShared()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/FS/listShared.md',
|
||||
path: '/FS/listShared',
|
||||
},
|
||||
{
|
||||
title: '<code>getShares()</code>',
|
||||
page_title: '<code>puter.fs.getShares()</code>',
|
||||
title_tag: 'puter.fs.getShares()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/FS/getShares.md',
|
||||
path: '/FS/getShares',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user