mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-25 14:46:44 +00:00
✨ PUT-1497: Share file link sharing and notifications (#3595)
* refactor(share): move share notifications into their own service
* feat(share): invite an address with no account, and email it
* feat(share): surface pending invites in the SDK and share dialog
* fix(share): unreachable revoke confirmation, and double-encoded labels
* feat(share): budget share announcements, group them, and let people block senders
Sharing had one defence against noise: a 15-minute quiet window per (sender,
recipient) pair, which dropped the second share rather than folding it in.
Twenty senders each under their own window could still bury someone, and there
was no way to make one of them stop.
Announcements are now budgeted on two axes through the existing sliding-window
limiter: 1 per 15 minutes and 20 per day from one sender, and 10 per hour /
50 per day to one recipient from anyone. Over budget the share still succeeds
and the recipient's notification is still brought up to date — only the
interruption is dropped. Invite email to an address with no account is budgeted
the same way, keyed on a hash of the canonical address.
Notifications now fold across senders: a new share rewrites the notification
the recipient hasn't dismissed, so "alice and bob shared 5 items with you"
replaces a stack of five. The record is written even when suppressed, so the
count is right whenever they next look.
Blocking is a new `user_block` table with enforcement in ShareService: a blocked
sender's share is refused with `recipient_not_accepting_shares`, spends no
quota, and writes no row, and their unclaimed invite is dropped when the address
is confirmed. Existing access is untouched — that is what revoke is for.
Managed from a Blocked people card in the dashboard's Security tab.
Also publishes the sharing limits, including the ones already on this branch
that were never documented.
* fix(share): name the item in share email, instead of 'an item'
* fix(share): make the invite lifecycle canonical, authorized, and race-safe
* refactor(email): drop EmailClient.isConfigured; callers read config.email
* feat(share): batch share email into a per-recipient digest, durably
* docs(share): document the share error codes; steady the disk migration tests
* fix(share): log why a digest wasn't sent, and recover orphaned ones
* feat(share): email recipients about shares by default, with a way to decline
Share email was off unless a deployment opted in, which meant an account
holder was told about a share in the app only. It is now on unless
`share_email_notifications` is set to false.
The reason it defaulted off was that nobody could decline. So this also
honors `user.unsubscribed` — the account-wide opt-out the /unsubscribe page
already writes and app feedback already respects, which share email ignored —
and the digest carries that link. Sharing and the in-app notification are
unaffected by it; only the mail stops.
The link is composed in the template around an interpolated uuid rather than
passed pre-built: Handlebars escapes interpolated values, so a whole URL came
out as `user_uuid=…`, which browsers decode but link scanners and older
mail clients need not.
* fix(share): count every shared file in the digest, not just the first
* feat(share): let a recipient refuse shares from everyone
Blocking answered "not from this person" but had no answer to "not from
anyone", so the only way to stop a stream of unwanted shares was to name
each sender after they had already reached you.
Stored as a key in the user row's existing `metadata` blob rather than a
column: the share path already holds the recipient's row by the time it
asks, so reading it costs nothing, and a one-bit preference doesn't earn
a migration per dialect. `updateMetadata` merges and refreshes the cached
row, so the switch bites on the very next share.
Refusing everyone reports the same code as refusing one person — which of
the two it is is the recipient's business, not the sender's. Enforced at
both moments the per-sender block is: when the share is issued, and when
a pending invite is claimed. The per-sender list is untouched while the
blanket switch is on, so turning it off restores what it hid.
`GET /share/blocks` now carries `all`; `POST`/`DELETE` take `{ all: true }`
beside the existing `{ username }`. Managed from the same Blocked people
card in the dashboard's Security tab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): keep the digest sweep off a window that still has a timer
The sweep treated an entry as orphaned the moment its window closed, which
is also the moment the node that armed it fires. Claiming an entry is only
exclusive among flushers that can see each other's deletes, so the two
could each claim a share of the same digest and both send. It now waits
out a grace period first, which costs a genuinely stranded digest that
much delay and nothing else.
Both digest listings were capped at 200 with no word when they hit it — a
truncated flush sends a digest that undercounts and reads as complete.
The cap is named and logged.
Also: `#emailHolder` still described share email as off by default, which
it stopped being; the config doc said the batch window defaults to 60s
when it is 90; and the two tests that need several calls inside one window
were racing a 50ms window across four sequential round trips, so they
failed under full-suite load rather than on the behaviour they cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): stop double-encoding the recipient in two dialog messages
`i18n()` encodes what it returns, replacements included, so encoding the
recipient first showed the entities to anyone whose address or username
contains one. Same pattern already fixed two lines above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(notification): widen the mysql shown/acknowledged columns
Both arrived from the v1 schema as `tinyint(1)`, where they were flags.
The backend rework changed the writes to a unix second; sqlite (`INTEGER`)
and postgres (`bigint`) took it and mysql did not, so on mysql every
`markShown` and `markAcknowledged` has failed with
ER_WARN_DATA_OUT_OF_RANGE and left the column NULL. Dismissing a
notification never stuck — the unacknowledged count never moved and one
already delivered came back on every reconnect.
No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a
legacy `1` keeps meaning "yes" once widened. Guarded on the current type,
because changing a column type copies the table and this directory
replays on every boot.
Not reachable from the test suite — it runs against sqlite and postgres,
both of which already have the right type. Verified by hand against mysql:
`/notif/mark-read` and `/notif/mark-ack` now persist, and a dismissed
share notification is no longer the one a later share folds into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
Daniel Salazar
parent
401b6066b3
commit
b795b219a8
@@ -44,6 +44,8 @@ A `Promise` that resolves to an array of share objects, each with `uid`, `mode`,
|
||||
|
||||
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.
|
||||
|
||||
It also includes **invitations** — shares aimed at an email address with no confirmed account yet. Those carry `pending: true`, a `null` `holder`, and the address in `recipientEmail`. They grant nothing until the recipient confirms that address, and [`unshare()`](/FS/unshare/) cancels one before it is claimed.
|
||||
|
||||
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
|
||||
|
||||
@@ -59,11 +59,14 @@ A `Promise` that resolves to an array of share objects, one per recipient/item p
|
||||
- `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/)).
|
||||
- `name` (String) - Name of the shared item. The masked path hides the folder it sits in, so this is what to label it with.
|
||||
- `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.
|
||||
- `pending` (Boolean) - Present and `true` when the recipient's email has no confirmed Puter account. See below.
|
||||
- `recipientEmail` (String) - Address a pending share was sent to. Only set when `pending`.
|
||||
- `modified` (Number) - Last-modified time of the item, in unix seconds.
|
||||
- `size` (Number) - Size of the item in bytes; `null` for a directory.
|
||||
|
||||
@@ -71,6 +74,41 @@ Sharing the same item with the same person again **replaces** their access rathe
|
||||
|
||||
If some recipients succeed and others fail, the promise resolves with the ones that worked. It rejects only when every pair failed.
|
||||
|
||||
## Errors
|
||||
|
||||
A rejection carries `{ message, code }`. Because each recipient/item pair succeeds or fails on its own, these are the codes of the *pairs* that failed — you only see one as a rejection when every pair failed.
|
||||
|
||||
| `code` | Meaning |
|
||||
| --- | --- |
|
||||
| `subject_does_not_exist` | No such item, or you cannot see it. Also what a caller without permission to share gets, so the response never reveals which. |
|
||||
| `forbidden` | You can see the item but may not share it at the level you asked for. |
|
||||
| `user_does_not_exist` | The username has no account. (An unknown *email* is invited instead — see below.) |
|
||||
| `recipient_not_accepting_shares` | The recipient is not accepting this share — they have blocked you, or turned off new shares from everyone. Nothing is granted and they are not notified. Which of the two it is is not reported. |
|
||||
| `email_not_allowed` | The address can't receive an invite — malformed, or refused by the deployment's policy. |
|
||||
| `cannot_share_with_self` | You are the recipient. |
|
||||
| `cannot_share_with_owner` | The recipient already owns the item. |
|
||||
| `invalid_mode` | `mode` is not one of `see`, `list`, `read`, `write`, `manage`. |
|
||||
| `share_daily_limit_reached` | You have handed out as many new shares as one account may per day (see [rate limits](/rate-limits-and-quotas/)). |
|
||||
| `too_many_recipients`, `too_many_items` | One call's fan-out cap; split the request. |
|
||||
|
||||
## Sharing with someone who has no account
|
||||
|
||||
A **well-formed** email address with no confirmed Puter account is **invited** rather than refused. The share is recorded and the recipient is emailed, but it grants nothing yet — the returned share carries `pending: true` and a `null` `holder`. An address that could never receive that invite is rejected with `email_not_allowed` instead of becoming an invite nobody can claim.
|
||||
|
||||
Access is written when they create an account with that address **and confirm it**. Signing up alone is not enough: until the address is confirmed it is a claim rather than an identity, and honouring it would hand the share to whoever registered it first.
|
||||
|
||||
An invite shows up in [`getShares()`](/FS/getShares/) with `pending: true`, and [`unshare()`](/FS/unshare/) cancels it.
|
||||
|
||||
```js
|
||||
const [share] = await puter.fs.share('report.txt', 'newcomer@example.com');
|
||||
|
||||
if ( share.pending ) {
|
||||
puter.print(`Invited ${share.recipientEmail} — access starts when they join`);
|
||||
} else {
|
||||
puter.print(`Shared with ${share.holder}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
<strong class="example-title">Share a file with another user</strong>
|
||||
|
||||
@@ -55,6 +55,8 @@ 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.
|
||||
|
||||
Passing an email address that was **invited** but has not yet joined cancels the invitation. Nothing was granted, so nothing is revoked from anyone — the pending share simply stops waiting.
|
||||
|
||||
## Examples
|
||||
|
||||
<strong class="example-title">Stop sharing a file</strong>
|
||||
|
||||
@@ -100,6 +100,32 @@ Signed-URL routes have no session to key on, so they are bounded per network rat
|
||||
| Concurrent worker calls | 10 | 5 | 3 |
|
||||
| Concurrent deploys | 5 | 2 | 2 |
|
||||
|
||||
### Sharing
|
||||
|
||||
Sharing is bounded twice: on the calls, and on how many people one account can reach in a day.
|
||||
|
||||
| Limit | All accounts |
|
||||
| --- | --- |
|
||||
| `share` / `revoke` calls per minute | 60 |
|
||||
| `share` / `revoke` calls per day | 500 |
|
||||
| Reads (`getShares`, `listShared`) per minute | 600 |
|
||||
| New shares per day | 200 |
|
||||
| Recipients per request | 10 |
|
||||
| Items per request | 50 |
|
||||
|
||||
A "new share" is one that gives someone access they didn't already have. Changing the mode on an existing share, or re-sharing an item the recipient already has, costs nothing. Over the daily limit, `share` fails with `share_daily_limit_reached`.
|
||||
|
||||
Separately, the notification and email that tell a recipient about a share are budgeted — being told is not the same as being interrupted about it:
|
||||
|
||||
| Announcement | Limit |
|
||||
| --- | --- |
|
||||
| From one sender to one recipient | 1 per 15 minutes, 20 per day |
|
||||
| To one recipient, from anyone | 10 per hour, 50 per day |
|
||||
|
||||
Recipients are emailed by default and opt out with the unsubscribe link the mail carries; a deployment can turn share email off entirely with `share_email_notifications: false`.
|
||||
|
||||
Over these, **the share still succeeds** — only the announcement is dropped. The recipient's notification is kept up to date either way, and folds several senders into one ("alice and bob shared 5 items with you"), so nothing is lost; it just doesn't interrupt them again. Emails are additionally batched: everything triggered for one recipient within a 90-second window goes as a single digest message. Recipients can also refuse shares outright — from one sender, or from everyone — which fails that sender's `share` call with `recipient_not_accepting_shares`. Both are managed from **Settings → Security → Blocked people**.
|
||||
|
||||
### Everything at once
|
||||
|
||||
Every driver call also passes one shared per-account budget of **8,000 calls/min** before the per-API limits above. It exists to catch a runaway loop, not to shape normal traffic — a client that sees a 429 from it is looping.
|
||||
|
||||
Reference in New Issue
Block a user