From b795b219a86868e999399feb7b7efc026f844ce9 Mon Sep 17 00:00:00 2001 From: Juan Fernando Castro Date: Tue, 18 Aug 2026 12:36:33 -0400 Subject: [PATCH] :sparkles: PUT-1497: Share file link sharing and notifications (#3595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) --------- Co-authored-by: Daniel Salazar Co-authored-by: Claude Opus 5 (1M context) --- config.template.jsonc | 22 + doc/self-hosting.md | 12 + extensions/whoami.ts | 3 +- .../database/SqliteDatabaseClient.test.ts | 17 +- .../clients/database/SqliteDatabaseClient.ts | 1 + .../migrations/mysql/mysql_mig_23.sql | 35 + .../migrations/mysql/mysql_mig_24.sql | 61 ++ .../migrations/postgres/postgres_mig_12.sql | 33 + .../migrations/sqlite/0069_user-block.sql | 39 + src/backend/clients/dynamodb/DDBClient.ts | 12 +- src/backend/clients/email/EmailClient.test.ts | 17 +- src/backend/clients/email/EmailClient.ts | 8 +- src/backend/clients/email/templates.ts | 60 ++ .../feedback/AppFeedbackController.test.ts | 16 +- .../share/ShareController.http.test.ts | 215 ++++- .../controllers/share/ShareController.ts | 193 +++- .../ai-chat/providers/alibaba/models.ts | 6 +- .../ai-chat/providers/mistral/models.ts | 3 +- .../ai-chat/providers/moonshot/models.ts | 6 +- src/backend/services/auth/OIDCService.ts | 19 + .../feedback/AppFeedbackService.test.ts | 18 +- .../services/feedback/AppFeedbackService.ts | 4 +- src/backend/services/index.ts | 5 + .../notification/NotificationService.ts | 68 +- .../share/ShareNotificationService.test.ts | 473 ++++++++++ .../share/ShareNotificationService.ts | 870 +++++++++++++++++ .../services/share/ShareService.test.ts | 875 +++++++++++++++++- src/backend/services/share/ShareService.ts | 563 +++++++++-- src/backend/services/share/shareEmail.test.ts | 666 +++++++++++++ .../services/share/shareNotifyTitle.test.ts | 176 ++++ .../services/share/shareNotifyTitle.ts | 194 ++++ src/backend/stores/index.ts | 5 +- .../stores/notification/NotificationStore.js | 21 + src/backend/stores/share/ShareStore.js | 103 +++ .../stores/systemKv/SystemKVStore.test.ts | 16 + src/backend/stores/systemKv/SystemKVStore.ts | 69 +- .../stores/userBlock/UserBlockStore.test.ts | 85 ++ .../stores/userBlock/UserBlockStore.ts | 100 ++ src/backend/types.ts | 47 +- src/docs/src/FS/getShares.md | 2 + src/docs/src/FS/share.md | 38 + src/docs/src/FS/unshare.md | 2 + src/docs/src/rate-limits-and-quotas.md | 26 + src/gui/src/UI/Dashboard/TabSecurity.js | 27 + src/gui/src/UI/UIDesktop.js | 10 + src/gui/src/UI/UIWindowBlockedSenders.js | 210 +++++ src/gui/src/UI/UIWindowShare.js | 53 +- src/gui/src/css/style.css | 21 + src/gui/src/i18n/translations/en.js | 24 + .../modules/FileSystem/operations/share.js | 4 +- .../FileSystem/operations/shareUtil.js | 8 + src/puter-js/src/modules/FileSystem/types.js | 8 +- .../tests/api/suites/sharing.suite.ts | 30 + 53 files changed, 5396 insertions(+), 203 deletions(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_23.sql create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_24.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_12.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0069_user-block.sql create mode 100644 src/backend/services/share/ShareNotificationService.test.ts create mode 100644 src/backend/services/share/ShareNotificationService.ts create mode 100644 src/backend/services/share/shareEmail.test.ts create mode 100644 src/backend/services/share/shareNotifyTitle.test.ts create mode 100644 src/backend/services/share/shareNotifyTitle.ts create mode 100644 src/backend/stores/userBlock/UserBlockStore.test.ts create mode 100644 src/backend/stores/userBlock/UserBlockStore.ts create mode 100644 src/gui/src/UI/UIWindowBlockedSenders.js diff --git a/config.template.jsonc b/config.template.jsonc index 22a3271bf..56a0268d3 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -251,6 +251,28 @@ } }, + // ── Sharing ───────────────────────────────────────────────────────── + // Email a recipient who already has an account about a new share. On + // unless set to false; they opt out via the unsubscribe link, or by + // blocking a sender. An invite to an address with no account always goes. + // "share_email_notifications": false, + // + // New shares one account may create per UTC day. Default 200. + // "share_daily_limit": 200, + // + // How often a share may interrupt its recipient — the notification pushed + // to their screen and the email with it. Over budget the share still + // succeeds and their notification is still brought up to date; only the + // interruption is dropped. The recipient bounds are what stop many senders + // burying one person between them. A non-positive value removes a bound. + // "share_notify_limits": { + // "pairWindowSeconds": 900, + // "pairDaily": 20, + // "recipientHourly": 10, + // "recipientDaily": 50, + // "emailBatchSeconds": 90 + // }, + // ── Alarms / alerting ─────────────────────────────────────────────── // Where system alarms go. Severity is the routing decision — each // transport takes everything at or above its own `minSeverity`: diff --git a/doc/self-hosting.md b/doc/self-hosting.md index daaad61b3..d470174e1 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -300,6 +300,18 @@ Used for password resets, email confirmation, and notifications. Without it thos To require email confirmation before login, also set `"strict_email_verification_required": true`. +To read the mail yourself instead of sending it, point the transport at a local SMTP sink — [MailHog](https://github.com/mailhog/MailHog) catches everything and serves it at `http://localhost:8025`. Publish both ports; a container that only exposes them is unreachable from the host: + +```sh +docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog +``` + +```json +"email": { "from": "\"Puter\" ", "host": "127.0.0.1", "port": 1025, "secure": false, "ignoreTLS": true } +``` + +Share email is on once a transport exists: a recipient who already has an account is emailed as well as notified in-app, and they opt out with the unsubscribe link the mail carries. Set `"share_email_notifications": false` to keep those in the app only — an address with no account is still emailed, since there is nothing else to reach them with. + ### Sign in with Google (or another OIDC provider) ```json diff --git a/extensions/whoami.ts b/extensions/whoami.ts index 1a1016f4e..691374ab1 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -263,8 +263,7 @@ export const handleWhoami = async ( } const subscription = details.subscription as - | { offering?: Record } - | undefined; + { offering?: Record } | undefined; if (subscription?.offering) { delete subscription.offering.group; delete subscription.offering.benefits; diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index df4ca6d82..931a2bdbe 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,15 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 64; +const CURRENT_SCHEMA_VERSION = 65; + +/** + * These suites migrate real files on disk. Idle they finish in well under a + * second, but the whole migration chain runs against the filesystem — enough + * that a loaded runner blows the 5s default and reports a timeout where there + * is no fault. + */ +const DISK_MIGRATION_TIMEOUT_MS = 30_000; const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238'; const sqliteConfig = ( @@ -52,7 +60,7 @@ const userVersionOf = async (client: SqliteDatabaseClient): Promise => { return row.user_version as number; }; -describe('SqliteDatabaseClient — boot and migrations', () => { +describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATION_TIMEOUT_MS }, () => { let client: SqliteDatabaseClient; beforeEach(async () => { @@ -137,7 +145,10 @@ describe('SqliteDatabaseClient — boot and migrations', () => { }); }); -describe('SqliteDatabaseClient — legacy version inference', () => { +describe( + 'SqliteDatabaseClient — legacy version inference', + { timeout: DISK_MIGRATION_TIMEOUT_MS }, + () => { let dir: string; beforeEach(() => { diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index c0b18c33c..086534cda 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -98,6 +98,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [61, ['0066_owned-email-unique.sql']], [62, ['0067_share_entries.sql']], [63, ['0068_referral-code-unique.sql']], + [64, ['0069_user-block.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_23.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_23.sql new file mode 100644 index 000000000..8401156ce --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_23.sql @@ -0,0 +1,35 @@ +-- 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 . + +-- One user refusing contact from another. See sqlite/0068_user-block.sql for +-- the rationale. `created_at` is unix seconds. +-- +-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline, +-- so the directory can replay safely. + +CREATE TABLE IF NOT EXISTS `user_block` ( + `id` INT NOT NULL AUTO_INCREMENT, + `blocker_user_id` INT UNSIGNED NOT NULL, + `blocked_user_id` INT UNSIGNED NOT NULL, + `created_at` BIGINT NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_user_block_pair` (`blocker_user_id`, `blocked_user_id`), + CONSTRAINT `fk_user_block_blocker` FOREIGN KEY (`blocker_user_id`) + REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_user_block_blocked` FOREIGN KEY (`blocked_user_id`) + REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_24.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_24.sql new file mode 100644 index 000000000..b244c2955 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_24.sql @@ -0,0 +1,61 @@ +-- 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 . + +-- Widen `notification.shown` and `notification.acknowledged` to hold the unix +-- second the store has been writing to them since the backend rework. +-- +-- Both arrived from the v1 schema as `tinyint(1)`, where they were flags. The +-- rework changed the writes to timestamps and sqlite (`INTEGER`) and postgres +-- (`bigint`) took them; only mysql was left too narrow, so every `markShown` +-- and `markAcknowledged` there fails with ER_WARN_DATA_OUT_OF_RANGE and the +-- column stays NULL. Dismissing a notification therefore never sticks, and one +-- already delivered is re-sent 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 rather than run unconditionally. Changing a +-- column type copies the table, and this one grows without bound — a migration +-- directory that replays on every boot must not rebuild it every time. + +DROP PROCEDURE IF EXISTS _puter_widen_notification_stamps; +DELIMITER // +CREATE PROCEDURE _puter_widen_notification_stamps() +BEGIN + IF EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'notification' + AND COLUMN_NAME = 'shown' + AND DATA_TYPE = 'tinyint' + ) THEN + ALTER TABLE `notification` MODIFY `shown` BIGINT DEFAULT NULL; + END IF; + + IF EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'notification' + AND COLUMN_NAME = 'acknowledged' + AND DATA_TYPE = 'tinyint' + ) THEN + ALTER TABLE `notification` MODIFY `acknowledged` BIGINT DEFAULT NULL; + END IF; +END // +DELIMITER ; +CALL _puter_widen_notification_stamps(); +DROP PROCEDURE IF EXISTS _puter_widen_notification_stamps; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_12.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_12.sql new file mode 100644 index 000000000..f19229272 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_12.sql @@ -0,0 +1,33 @@ +-- 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 . + +-- One user refusing contact from another. See sqlite/0068_user-block.sql for +-- the rationale. `created_at` is unix seconds. +-- +-- Idempotent via IF NOT EXISTS. + +CREATE TABLE IF NOT EXISTS user_block ( + id BIGSERIAL PRIMARY KEY, + blocker_user_id integer NOT NULL + REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE, + blocked_user_id integer NOT NULL + REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE, + created_at BIGINT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_block_pair + ON user_block (blocker_user_id, blocked_user_id); diff --git a/src/backend/clients/database/migrations/sqlite/0069_user-block.sql b/src/backend/clients/database/migrations/sqlite/0069_user-block.sql new file mode 100644 index 000000000..c2bdf4e3e --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0069_user-block.sql @@ -0,0 +1,39 @@ +-- 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 . + +-- One user refusing contact from another. Sharing is what reads it today — +-- a blocked sender's share is refused outright, and their pending invite is +-- dropped when the blocker confirms the address it was aimed at — but the +-- table is deliberately not share-specific: the same answer serves anything +-- else one person can push at another. +-- +-- Rows are read on the share path by exact (blocker, blocked) pair, which the +-- unique index answers as a point lookup; the same index's leading column +-- serves "who have I blocked". `created_at` is unix seconds, matching +-- `app_feedback`. + +CREATE TABLE IF NOT EXISTS `user_block` ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "blocker_user_id" INTEGER NOT NULL + REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "blocked_user_id" INTEGER NOT NULL + REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "created_at" INTEGER NOT NULL -- unix seconds +); + +CREATE UNIQUE INDEX IF NOT EXISTS `idx_user_block_pair` + ON `user_block` (`blocker_user_id`, `blocked_user_id`); diff --git a/src/backend/clients/dynamodb/DDBClient.ts b/src/backend/clients/dynamodb/DDBClient.ts index 1b8e4d2aa..d23b3cdf0 100644 --- a/src/backend/clients/dynamodb/DDBClient.ts +++ b/src/backend/clients/dynamodb/DDBClient.ts @@ -355,11 +355,18 @@ export class DDBClient extends PuterClient { } @Span('ddb.del', (table: string) => ({ 'db.table': table })) - async del>(table: string, key: T) { + async del>( + table: string, + key: T, + opts?: { returnOld?: boolean }, + ) { const command = new DeleteCommand({ TableName: table, Key: key, ReturnConsumedCapacity: 'TOTAL', + // ALL_OLD makes the delete an atomic claim: exactly one caller + // gets the attributes back. + ...(opts?.returnOld ? { ReturnValues: 'ALL_OLD' as const } : {}), }); const client = await this.#getDocumentClient(); @@ -628,8 +635,7 @@ export class DDBClient extends PuterClient { ); lastEvaluatedKey = scan.LastEvaluatedKey as - | Record - | undefined; + Record | undefined; const items = scan.Items; if (!items || items.length === 0) continue; diff --git a/src/backend/clients/email/EmailClient.test.ts b/src/backend/clients/email/EmailClient.test.ts index a409f6e75..71db9ea14 100644 --- a/src/backend/clients/email/EmailClient.test.ts +++ b/src/backend/clients/email/EmailClient.test.ts @@ -54,14 +54,22 @@ const sentMessage = (info: unknown) => }; describe('EmailClient — transport lifecycle', () => { - it('reports as configured once a transport is wired up', () => { + it('stops sending once the transport is shut down', async () => { const client = startClient(); - expect(client.isConfigured).toBe(true); + expect( + await client.sendRaw({ to: 'a@b.test', subject: 'up', text: 'x' }), + ).not.toBeNull(); + client.onServerShutdown(); - expect(client.isConfigured).toBe(false); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect( + await client.sendRaw({ to: 'a@b.test', subject: 'down', text: 'x' }), + ).toBeNull(); + warn.mockRestore(); }); - it('warns and stays unconfigured when no transport is set', () => { + it('warns at boot when no transport is set', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const client = new EmailClient({ port: 0, @@ -69,7 +77,6 @@ describe('EmailClient — transport lifecycle', () => { } as unknown as IConfig); client.onServerStart(); - expect(client.isConfigured).toBe(false); expect(warn).toHaveBeenCalledWith( expect.stringContaining('no email transport configured'), ); diff --git a/src/backend/clients/email/EmailClient.ts b/src/backend/clients/email/EmailClient.ts index e12318976..814df6909 100644 --- a/src/backend/clients/email/EmailClient.ts +++ b/src/backend/clients/email/EmailClient.ts @@ -183,7 +183,8 @@ export class EmailClient extends PuterClient { * * Returns the transport's send result, or `null` when no transport is * configured (the send is a no-op in that case — callers that must not - * silently drop mail should check `isConfigured` first). + * silently drop mail should check `config.email` before composing, or the + * `null` return after). */ async sendRaw(options: SendMailOptions) { if (!this.transport) { @@ -199,11 +200,6 @@ export class EmailClient extends PuterClient { }); } - /** Whether an SMTP transport is configured (sends are no-ops otherwise). */ - get isConfigured(): boolean { - return this.transport !== null; - } - // -- Public API: clean / validate --------------------------------- /** diff --git a/src/backend/clients/email/templates.ts b/src/backend/clients/email/templates.ts index 861517662..db4ed39a1 100644 --- a/src/backend/clients/email/templates.ts +++ b/src/backend/clients/email/templates.ts @@ -183,6 +183,66 @@ immediately

Puter

`, }, + /** + * A digest: shares to one recipient are held briefly and merged, so + * `shares` may carry several senders. The subject is composed by the + * service (see `digestSubject`), which owns the grouped wording. + */ + file_shared_with_you: { + subject: '{{subject_line}}', + html: ` +
+

Hi {{recipient}},

+

Shared with you on Puter:

+ + {{#each shares}} + + + + {{/each}} +
+ {{this.sender}} shared {{this.what}} +
+

+ Open Puter +

+

Sincerely,
Puter

+ {{#if unsubscribe_uuid}} +

+ Don't want these? Unsubscribe. +

+ {{/if}} +
+ `, + }, + // The only way to reach someone with no account. Same digest shape. + file_shared_invite: { + subject: '{{subject_line}}', + html: ` +
+

Hi there,

+

Shared with you on Puter:

+ + {{#each shares}} + + + + {{/each}} +
+ {{this.sender}} shared {{this.what}} +
+

+ You don't have a Puter account for this address yet. Create one with + {{email}} and confirm it, and what was shared will be + waiting for you. +

+

+ Create your account +

+

Sincerely,
Puter

+
+ `, + }, share_by_username: { subject: 'Puter share from {{susername}}', html: ` diff --git a/src/backend/controllers/feedback/AppFeedbackController.test.ts b/src/backend/controllers/feedback/AppFeedbackController.test.ts index 122c49eda..79b6df56a 100644 --- a/src/backend/controllers/feedback/AppFeedbackController.test.ts +++ b/src/backend/controllers/feedback/AppFeedbackController.test.ts @@ -55,6 +55,8 @@ afterAll(async () => { afterEach(() => { vi.restoreAllMocks(); + // Back to the self-hosted no-SMTP baseline the server booted with. + delete liveConfig().email; }); const makeUser = async (): Promise<{ actor: Actor; userId: number }> => { @@ -101,11 +103,15 @@ const makeApp = async ( // Feedback is only offered when the deployment can deliver it (email // transport configured); most tests want that baseline without asserting -// anything about the mail itself. -const mockEmailConfigured = () => - vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue( - true, - ); +// anything about the mail itself. The service reads `config.email` — the +// same live object every layer holds — so the helper writes it there and +// the global afterEach clears it. +const liveConfig = () => + (server.clients.email as unknown as { config: Record }) + .config; +const mockEmailConfigured = () => { + liveConfig().email = { jsonTransport: true }; +}; const confirmOwnerEmail = async (userId: number) => { await server.clients.db.write( diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts index cd69f17fb..39526349b 100644 --- a/src/backend/controllers/share/ShareController.http.test.ts +++ b/src/backend/controllers/share/ShareController.http.test.ts @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; /** @@ -64,7 +64,7 @@ describe('share endpoints over HTTP', () => { '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 }; + return { uid, path, name }; }; it('shares an item, lists it for the recipient, then revokes it', async () => { @@ -80,10 +80,11 @@ describe('share endpoints over HTTP', () => { expect(shareRes.status).toBe(200); const shareBody = (await shareRes.json()) as { status: string; - results: Array<{ status: string; mode?: string }>; + results: Array<{ status: string; mode?: string; name?: string }>; }; expect(shareBody.status).toBe('success'); expect(shareBody.results[0].mode).toBe('read'); + expect(shareBody.results[0].name).toBe(file.name); const listRes = await get( '/share/shared-with-me', @@ -191,6 +192,68 @@ describe('share endpoints over HTTP', () => { expect(await revokeRes.json()).toMatchObject({ revoked: 1 }); }); + it('tells the recipient over the wire, once for the whole batch', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const [a, b] = [await makeFile(owner), await makeFile(owner)]; + + // These accounts are shared between tests, so both things that would + // otherwise decide this outcome are cleared first: the budgets that + // silence a repeat interruption, and any notification still open for + // this recipient, which a new share folds into instead of creating one. + // What's left under test is the batching. + const [issuer, holder] = await Promise.all([ + env.server.stores.user.getByUsername(owner.username), + env.server.stores.user.getByUsername(recipient.username), + ]); + await env.server.clients.redis.del( + `rate:share:notify:pair:${issuer!.id}:${holder!.id}`, + `rate:share:notify:pair-day:${issuer!.id}:${holder!.id}`, + `rate:share:notify:to:${holder!.id}`, + `rate:share:notify:to-day:${holder!.id}`, + ); + for (const row of await env.server.stores.notification.listByUserId( + holder!.id, + { filter: 'unacknowledged' }, + )) { + await env.server.stores.notification.markAcknowledged( + row.uid, + holder!.id, + ); + } + + const seen: Array<{ userIds: number[]; payload: Record }> = []; + const notification = env.server.services.notification; + const original = notification.notify.bind(notification); + notification.notify = async (userIds, payload) => { + seen.push({ userIds, payload }); + return original(userIds, payload); + }; + + try { + const res = await post('/share', owner.token, { + items: [{ path: a.path }, { path: b.path }], + recipients: [{ username: recipient.username }], + mode: 'read', + }); + expect(res.status).toBe(200); + + // Delivery is off the response path, so it may land just after. + await vi.waitFor(() => expect(seen.length).toBeGreaterThan(0), { + timeout: 5000, + }); + } finally { + notification.notify = original; + } + + // Two items, one recipient — one notification carrying the count. + expect(seen).toHaveLength(1); + expect(seen[0].payload).toMatchObject({ + source: 'sharing', + fields: { count: 2 }, + }); + }); + it('reports per-pair outcomes when only some recipients resolve', async () => { const owner = env.users.user; const file = await makeFile(owner); @@ -243,6 +306,32 @@ describe('share endpoints over HTTP', () => { expect(res.status).toBe(404); }); + + it('runs a duplicated pair once, and answers for both positions', async () => { + const owner = env.users.user; + const file = await makeFile(owner); + const invitee = `dup-${crypto.randomUUID().slice(0, 8)}@puter.local`; + + // The same pair twice raced itself into two pending rows; it must + // execute once, with the one outcome reported in both positions. + const res = await post('/share', owner.token, { + recipients: [invitee], + items: [{ uid: file.uid }, { uid: file.uid }], + mode: 'read', + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + status: string; + results: Array<{ status: string; uid?: string }>; + }; + expect(body.results).toHaveLength(2); + expect(body.results[0].status).toBe('pending'); + expect(body.results[1]).toEqual(body.results[0]); + + const rows = await env.server.stores.share.listPendingByEmail(invitee); + expect(rows).toHaveLength(1); + }); + it('rejects an unauthenticated share', async () => { const res = await fetch(new URL('/share', env.apiOrigin), { method: 'POST', @@ -300,4 +389,124 @@ describe('share endpoints over HTTP', () => { ).status, ).toBe(400); }); + + it('blocks a sender, refuses their share, then unblocks them', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const file = await makeFile(owner); + + const blocked = await post('/share/blocks', recipient.token, { + username: owner.username, + }); + expect(blocked.status).toBe(200); + expect(await blocked.json()).toMatchObject({ + username: owner.username, + blocked: true, + created: true, + }); + + const refused = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + // A per-pair outcome, so the envelope reports it rather than the status. + expect(refused.status).toBe(200); + expect(await refused.json()).toMatchObject({ + status: 'aborted', + results: [{ status: 'error', code: 'recipient_not_accepting_shares' }], + }); + + const listed = await get('/share/blocks', recipient.token, {}); + expect(listed.status).toBe(200); + const body = (await listed.json()) as { + items: Array>; + }; + const row = body.items.find((i) => i.username === owner.username); + expect(row).toBeDefined(); + expect(typeof row?.created_at).toBe('number'); + // Nothing internal rides along. + for (const key of ['blocker_user_id', 'blocked_user_id', 'id']) { + expect(row).not.toHaveProperty(key); + } + + const unblocked = await fetch(new URL('/share/blocks', env.apiOrigin), { + method: 'DELETE', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${recipient.token}`, + }, + body: JSON.stringify({ username: owner.username }), + }); + expect(unblocked.status).toBe(200); + expect(await unblocked.json()).toMatchObject({ unblocked: true }); + + const shared = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(await shared.json()).toMatchObject({ status: 'success' }); + }); + + it('requires a username to block', async () => { + const res = await post('/share/blocks', env.users.other.token, {}); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ code: 'bad_request' }); + }); + + it('refuses every sender while the blanket switch is on', async () => { + const owner = env.users.user; + const recipient = env.users.other; + const file = await makeFile(owner); + const del = (body: unknown) => + fetch(new URL('/share/blocks', env.apiOrigin), { + method: 'DELETE', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${recipient.token}`, + }, + body: JSON.stringify(body), + }); + + const on = await post('/share/blocks', recipient.token, { all: true }); + expect(on.status).toBe(200); + expect(await on.json()).toMatchObject({ all: true, blocked: true }); + + const listed = await get('/share/blocks', recipient.token, {}); + expect(await listed.json()).toMatchObject({ all: true, items: [] }); + + const refused = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(await refused.json()).toMatchObject({ + status: 'aborted', + results: [ + { status: 'error', code: 'recipient_not_accepting_shares' }, + ], + }); + + const off = await del({ all: true }); + expect(off.status).toBe(200); + expect(await off.json()).toMatchObject({ all: false, blocked: false }); + + const shared = await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: file.uid }], + mode: 'read', + }); + expect(await shared.json()).toMatchObject({ status: 'success' }); + + // Cleaned up so a later assertion on this recipient isn't reading + // state this test left behind. + expect((await del({ all: true })).status).toBe(200); + }); + + it('keeps one caller\'s blocklist out of another\'s', async () => { + const res = await get('/share/blocks', env.users.admin.token, {}); + const body = (await res.json()) as { all: boolean; items: unknown[] }; + expect(body).toEqual({ all: false, items: [] }); + }); }); diff --git a/src/backend/controllers/share/ShareController.ts b/src/backend/controllers/share/ShareController.ts index c9b211cf2..45f5da79f 100644 --- a/src/backend/controllers/share/ShareController.ts +++ b/src/backend/controllers/share/ShareController.ts @@ -19,7 +19,7 @@ import type { Request, Response } from 'express'; import type { Actor } from '../../core/actor.js'; -import { Controller, Get, Post } from '../../core/http/decorators.js'; +import { Controller, Delete, Get, Post } from '../../core/http/decorators.js'; import { HttpError, isHttpError } from '../../core/http/HttpError.js'; import type { ResolvedShare, @@ -66,10 +66,13 @@ const LIST_LIMIT_CAP = 200; export const DEFAULT_MAX_RECIPIENTS = 10; export const DEFAULT_MAX_ITEMS = 50; -/** A success carries the created share; a failure carries why. */ +/** + * A success carries the created share; a failure carries why. `pending` is an + * invite: recorded, but not access anyone holds yet. + */ interface ShareOutcome { recipient: string; - status: 'success' | 'error'; + status: 'success' | 'error' | 'pending'; path?: string; uid?: string; mode?: string; @@ -113,13 +116,16 @@ export class ShareController extends PuterController { // 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. + // setUserUser is a read-modify-write, and a duplicated invite races + // itself into two pending rows — so duplicates in the request execute + // once and every original position reports that one outcome. const pairs = recipients.flatMap((recipient) => items.map((item) => ({ recipient, item })), ); + const { unique, indexOf } = this.#dedupePairs(pairs); - const settled = await runWithConcurrencyLimitSettled( - pairs, + const settledUnique = await runWithConcurrencyLimitSettled( + unique, SHARE_CONCURRENCY, async ({ recipient, item }) => { const share = await this.services.share.share(actor, { @@ -130,6 +136,7 @@ export class ShareController extends PuterController { return share; }, ); + const settled = indexOf.map((i) => settledUnique[i]); const results: ShareOutcome[] = await Promise.all( settled.map(async (outcome, index) => { @@ -142,7 +149,7 @@ export class ShareController extends PuterController { return { ...(await this.#toClientShare(share)), recipient: label, - status: 'success', + status: share.pending ? 'pending' : 'success', }; } return { @@ -154,18 +161,17 @@ export class ShareController extends PuterController { }), ); - // 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(() => {}); + // Off the response path: a share that landed must not be reported as + // failed because telling the recipient didn't. Fanned out from the + // unique outcomes, so a duplicated pair is not counted twice. + void this.services.shareNotification.notifyShared( + actor, + settledUnique + .filter((o) => o.status === 'fulfilled') + .map((o) => (o as PromiseFulfilledResult).value), + ); - const succeeded = results.filter((r) => r.status === 'success').length; + const succeeded = results.filter((r) => r.status !== 'error').length; res.json({ status: succeeded === results.length @@ -197,20 +203,29 @@ export class ShareController extends PuterController { const pairs = recipients.flatMap((recipient) => items.map((item) => ({ recipient, item })), ); + const { unique, indexOf } = this.#dedupePairs(pairs); - const settled = await runWithConcurrencyLimitSettled( - pairs, + const settledUnique = await runWithConcurrencyLimitSettled( + unique, SHARE_CONCURRENCY, ({ recipient, item }) => this.services.share.unshare(actor, { ...item, recipient }), ); + const settled = indexOf.map((i) => settledUnique[i]); - let revoked = 0; + // Totalled over the unique outcomes: a pair listed twice revoked its + // grants once. + const revoked = settledUnique.reduce( + (total, outcome) => + outcome.status === 'fulfilled' + ? total + outcome.value.revoked + : total, + 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 } : {}), @@ -295,8 +310,139 @@ export class ShareController extends PuterController { }); } + // -- Blocking ----------------------------------------------------- + + /** + * GET /share/blocks — who the caller is refusing shares from, and whether + * they are refusing everyone. + */ + @Get('/blocks', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listBlocks(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const { all, items } = + await this.services.share.listBlockedSenders(actor); + res.json({ + all, + items: items.map((item) => ({ + username: item.username, + created_at: item.createdAt, + })), + }); + } + + /** + * POST /share/blocks — stop accepting shares. `{ all: true }` refuses + * everyone; `{ username }` refuses one person. Idempotent either way: + * blocking twice is the state the caller asked for. + * + * Access already granted is untouched — `POST /share/revoke` is what + * withdraws that. + */ + @Post('/blocks', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIMIT, + }) + async createBlock(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const body = this.#body(req); + if (this.#isBlockAll(body)) { + const { all } = await this.services.share.setBlockAllSenders( + actor, + true, + ); + res.json({ all, blocked: true }); + return; + } + const { username, created } = await this.services.share.blockSender( + actor, + this.#username(body), + ); + res.json({ username, blocked: true, created }); + } + + /** + * DELETE /share/blocks — accept shares again. `{ all: true }` lifts the + * blanket refusal, leaving the per-sender list as it was. + */ + @Delete('/blocks', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIMIT, + }) + async deleteBlock(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const body = this.#body(req); + if (this.#isBlockAll(body)) { + const { all } = await this.services.share.setBlockAllSenders( + actor, + false, + ); + res.json({ all, blocked: false }); + return; + } + const { username, unblocked } = await this.services.share.unblockSender( + actor, + this.#username(body), + ); + res.json({ username, blocked: false, unblocked }); + } + // -- Helpers ------------------------------------------------------ + /** + * Collapse repeated (recipient, item) pairs to one execution. `indexOf` + * maps every original position onto its unique pair, so a request that + * names the same pair twice still gets a result in both positions — the + * same result, which is the truth of what happened. + */ + #dedupePairs( + pairs: T[], + ): { unique: T[]; indexOf: number[] } { + const unique: T[] = []; + const seen = new Map(); + const indexOf = pairs.map((pair) => { + const key = JSON.stringify([ + pair.recipient.email ?? null, + pair.recipient.username ?? null, + pair.item.uid ?? null, + pair.item.path ?? null, + ]); + let at = seen.get(key); + if (at === undefined) { + at = unique.length; + seen.set(key, at); + unique.push(pair); + } + return at; + }); + return { unique, indexOf }; + } + + /** + * Whether this call is about the blanket switch rather than one person. + * Only the literal `true` counts, so a client sending `all: false` + * alongside a username still means the username. + */ + #isBlockAll(body: Record): boolean { + return body.all === true; + } + + #username(body: Record): string { + const username = + typeof body.username === 'string' ? body.username.trim() : ''; + if (!username) { + throw new HttpError(400, '`username` is required', { + legacyCode: 'bad_request', + }); + } + return username; + } + /** * Only ever the username — never the internal id, and never an email the * caller didn't already supply. @@ -325,6 +471,9 @@ export class ShareController extends PuterController { ...(share.owner === undefined ? {} : { owner: share.owner.username }), + ...(share.pending + ? { pending: true, recipient_email: share.recipientEmail } + : {}), uid_entry: share.entryUid, is_dir: share.isDir, issuer: share.issuer.username, diff --git a/src/backend/drivers/ai-chat/providers/alibaba/models.ts b/src/backend/drivers/ai-chat/providers/alibaba/models.ts index f00e09709..badea6989 100644 --- a/src/backend/drivers/ai-chat/providers/alibaba/models.ts +++ b/src/backend/drivers/ai-chat/providers/alibaba/models.ts @@ -222,7 +222,10 @@ export const ALIBABA_MODELS: IChatModel[] = [ { puterId: 'alibaba:qwen/qwen3.8-max', id: 'qwen3.8-max', - modalities: { input: ['text', 'image', 'video', 'pdf'], output: ['text'] }, + modalities: { + input: ['text', 'image', 'video', 'pdf'], + output: ['text'], + }, open_weights: false, tool_call: true, release_date: '2026-08-03', @@ -241,7 +244,6 @@ export const ALIBABA_MODELS: IChatModel[] = [ max_tokens: 131_072, }, - // -- Turbo / Flash tier ----------------------------------------- { puterId: 'alibaba:qwen/qwen-turbo', diff --git a/src/backend/drivers/ai-chat/providers/mistral/models.ts b/src/backend/drivers/ai-chat/providers/mistral/models.ts index 08539224c..e8112d290 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/models.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/models.ts @@ -231,7 +231,8 @@ export const MISTRAL_MODELS: IChatModel[] = [ prompt_tokens: 4, completion_tokens: 4, }, - }, { + }, + { puterId: 'mistralai:mistralai/voxtral-small-2507', id: 'voxtral-small-2507', modalities: { input: ['text', 'audio'], output: ['text'] }, diff --git a/src/backend/drivers/ai-chat/providers/moonshot/models.ts b/src/backend/drivers/ai-chat/providers/moonshot/models.ts index 9b3eb0c36..6455f2cc6 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/models.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/models.ts @@ -73,10 +73,7 @@ export const MOONSHOT_MODELS: IChatModel[] = [ puterId: 'moonshotai:moonshotai/kimi-k2.7-code', id: 'kimi-k2.7-code', name: 'Kimi K2.7 Code', - aliases: [ - 'moonshotai/kimi-k2.7-code', - 'moonshot/kimi-k2.7-code', - ], + aliases: ['moonshotai/kimi-k2.7-code', 'moonshot/kimi-k2.7-code'], modalities: { input: ['text', 'image', 'video'], output: ['text'] }, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -116,7 +113,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [ release_date: '2026-06-12', }, - // -- Kimi K2.5 -------------------------------------------------- { puterId: 'moonshotai:moonshotai/kimi-k2.5', diff --git a/src/backend/services/auth/OIDCService.ts b/src/backend/services/auth/OIDCService.ts index f506a4062..e7a38c244 100644 --- a/src/backend/services/auth/OIDCService.ts +++ b/src/backend/services/auth/OIDCService.ts @@ -722,6 +722,25 @@ export class OIDCService extends PuterService { // Fire signup events — keys match the password-based signup path so // downstream listeners (welcome email, mailchimp sync, etc.) treat // both signup routes identically. + // + // That includes `user.email-confirmed`: the provider's attestation IS + // the confirmation, and anything keyed on owning a confirmed address — + // pending share invites, most importantly — has no other moment to + // fire. Without it, an invitee who follows the email and signs in with + // Google never receives what was shared with them. + try { + this.clients.event?.emit( + 'user.email-confirmed', + { + user_id: resolved.id, + user_uid: resolved.uuid, + email: resolved.email, + }, + {}, + ); + } catch { + // ignore — event emission shouldn't block signup + } try { this.clients.event?.emit( 'puter.signup.success', diff --git a/src/backend/services/feedback/AppFeedbackService.test.ts b/src/backend/services/feedback/AppFeedbackService.test.ts index 9a7c517e2..a2ec3ca2d 100644 --- a/src/backend/services/feedback/AppFeedbackService.test.ts +++ b/src/backend/services/feedback/AppFeedbackService.test.ts @@ -54,6 +54,8 @@ afterAll(async () => { afterEach(() => { vi.restoreAllMocks(); + // Back to the self-hosted no-SMTP baseline the server booted with. + delete liveConfig().email; }); const makeUser = async (): Promise => { @@ -85,9 +87,15 @@ const makeApp = async ( }; // Feedback is only offered when the deployment can deliver it; most tests -// want that baseline without asserting anything about the mail itself. -const mockEmailConfigured = () => - vi.spyOn(server.clients.email, 'isConfigured', 'get').mockReturnValue(true); +// want that baseline without asserting anything about the mail itself. The +// service reads `config.email` — the same live object every layer holds — so +// the helper writes it there and the global afterEach clears it. +const liveConfig = () => + (server.clients.email as unknown as { config: Record }) + .config; +const mockEmailConfigured = () => { + liveConfig().email = { jsonTransport: true }; +}; const mockEmailReady = () => { mockEmailConfigured(); @@ -236,7 +244,7 @@ describe('AppFeedbackService.acceptsFeedback', () => { // The self-hosted no-SMTP default. Feedback rows have no other read // path, so soliciting them here would store-and-lose every message // while telling the sender it was delivered. - expect(server.clients.email.isConfigured).toBe(false); + expect(liveConfig().email).toBeUndefined(); expect(service.acceptsFeedback(app)).toBe(false); }); }); @@ -338,7 +346,7 @@ describe('AppFeedbackService.submit', () => { ).rejects.toMatchObject({ statusCode: 403 }); // Same refusal when the deployment has no email transport at all. - vi.restoreAllMocks(); + delete liveConfig().email; const enabled = await makeApp(ownerId, { feedbackEnabled: true }); await expect( service.submit({ userId, app: enabled.name, message: 'hi' }), diff --git a/src/backend/services/feedback/AppFeedbackService.ts b/src/backend/services/feedback/AppFeedbackService.ts index 72aa3dd0f..e15caa5bb 100644 --- a/src/backend/services/feedback/AppFeedbackService.ts +++ b/src/backend/services/feedback/AppFeedbackService.ts @@ -122,7 +122,7 @@ export class AppFeedbackService extends PuterService { */ acceptsFeedback(app: Record | null): boolean { return Boolean( - this.clients.email.isConfigured && + this.config.email && app && app.feedback_enabled && app.owner_user_id, @@ -287,7 +287,7 @@ export class AppFeedbackService extends PuterService { senderUserId: number; since: number; }): Promise { - if (!this.clients.email.isConfigured) return; + if (!this.config.email) return; const owner = await this.stores.user.getById(Number(app.owner_user_id)); if ( diff --git a/src/backend/services/index.ts b/src/backend/services/index.ts index 1089d35c0..71f72eb93 100644 --- a/src/backend/services/index.ts +++ b/src/backend/services/index.ts @@ -37,6 +37,7 @@ import { MeteringService } from './metering/MeteringService'; import { NotificationService } from './notification/NotificationService'; import { PermissionService } from './permission/PermissionService'; import { DefaultUserService } from './selfhosted/DefaultUserService'; +import { ShareNotificationService } from './share/ShareNotificationService'; import { ShareService } from './share/ShareService'; import { SocketService } from './socket/SocketService'; import { SubdomainPermissionService } from './subdomain/SubdomainPermissionService'; @@ -57,6 +58,7 @@ declare module './types' { permission: PermissionService; acl: ACLService; share: ShareService; + shareNotification: ShareNotificationService; token: TokenService; auth: AuthService; fs: FSService; @@ -100,6 +102,9 @@ export const puterServices = { // Needs acl (setUserUser), permission (canManagePermission) and fs // (ancestor chains), so it follows all three. share: ShareService, + // Delivery only; it reaches `notification` at call time, so its position + // relative to that service does not matter. + shareNotification: ShareNotificationService, // Declared after `fs` — account teardown tears the user's filesystem down // first. userAccount: UserAccountService, diff --git a/src/backend/services/notification/NotificationService.ts b/src/backend/services/notification/NotificationService.ts index 110256f3c..fa5dfb002 100644 --- a/src/backend/services/notification/NotificationService.ts +++ b/src/backend/services/notification/NotificationService.ts @@ -90,28 +90,36 @@ export class NotificationService extends PuterService { * (`/notif/mark-ack`), and the delivery receipt below marks it shown — * neither can find a row otherwise. * + * `silent` persists without pushing: the recipient finds it when they next + * look, but nothing interrupts them now. For callers that budget how often + * they may interrupt someone and must still keep the record straight. + * * @param userIds Target user ids * @param notification Payload — { source, title, text?, icon?, template?, * fields? } + * @param opts `{ silent }` to skip the socket push. * @returns The uid of the first recipient's notification. */ async notify( userIds: number[], notification: Record, + opts: { silent?: boolean } = {}, ): Promise { const uidByIndex = userIds.map(() => uuidv4()); // Immediate socket push (before DB write completes) - userIds.forEach((userId, i) => { - this.clients.event.emit( - 'outer.gui.notif.message', - { - user_id_list: [userId], - response: { uid: uidByIndex[i], notification }, - }, - {}, - ); - }); + if (!opts.silent) { + userIds.forEach((userId, i) => { + this.clients.event.emit( + 'outer.gui.notif.message', + { + user_id_list: [userId], + response: { uid: uidByIndex[i], notification }, + }, + {}, + ); + }); + } // Async DB inserts — one row per user. userIds.forEach((userId, i) => { @@ -133,7 +141,9 @@ export class NotificationService extends PuterService { this.#pendingWrites.set(uid, writePromise); writePromise.finally(() => this.#pendingWrites.delete(uid)); - // Fire persisted event once that recipient's write completes + // Nothing was pushed when silent, so there is no delivery to + // confirm. + if (opts.silent) return; writePromise.then(() => { this.clients.event.emit( 'outer.gui.notif.persisted', @@ -149,6 +159,42 @@ export class NotificationService extends PuterService { return uidByIndex[0] ?? uuidv4(); } + /** + * Rewrite a notification the recipient hasn't dismissed and deliver it + * again — for a story that grows rather than repeats, where a second + * notification would just be noise. + * + * False when there was nothing to rewrite (dismissed in between), which is + * the caller's signal to send a fresh one. + */ + async notifyUpdate( + uid: string, + userId: number, + notification: Record, + opts: { silent?: boolean } = {}, + ): Promise { + const updated = await this.stores.notification.updateValue( + uid, + userId, + notification, + ); + if (!updated) return false; + + if (!opts.silent) { + // Same uid as the original: the client replaces what it is already + // showing rather than stacking another copy. + this.clients.event.emit( + 'outer.gui.notif.message', + { + user_id_list: [userId], + response: { uid, notification }, + }, + {}, + ); + } + return true; + } + /** * Mark a notification as acknowledged (user dismissed it) and push the ack * event to sockets so other tabs update. diff --git a/src/backend/services/share/ShareNotificationService.test.ts b/src/backend/services/share/ShareNotificationService.test.ts new file mode 100644 index 000000000..ed42be6a7 --- /dev/null +++ b/src/backend/services/share/ShareNotificationService.test.ts @@ -0,0 +1,473 @@ +/* + * 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, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { Actor } from '../../core/actor.js'; +import { PuterServer } from '../../server.js'; +import { createTestUser, setupTestServer } from '../../testUtil.js'; +import type { ResolvedShare } from './ShareService'; + +describe('ShareNotificationService', () => { + let server: PuterServer; + + beforeAll(async () => { + server = await setupTestServer(); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const makeUser = async () => { + const username = `sn${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'); + return user; + }; + + const actorFor = (user: { id: number; username: string }): Actor => + ({ + user: { id: user.id, username: user.username }, + effectiveApp: null, + }) as Actor; + + /** + * A share that granted reach the holder did not already have — the only + * kind worth interrupting someone for. + */ + const shareTo = ( + holder: { id: number; username: string }, + over: Partial = {}, + ): ResolvedShare => + ({ + uid: 'u', + mode: 'read', + path: '/somewhere/file.txt', + entryUid: 'e', + isDir: false, + issuer: { username: 'sender' }, + holder: { username: holder.username }, + holderId: holder.id, + isNew: true, + createdAt: null, + modified: 0, + size: null, + ...over, + }) as ResolvedShare; + + /** Captures what reached the notification layer. */ + const captureNotifications = () => { + const calls: Array<{ + userIds: number[]; + payload: Record; + silent: boolean; + }> = []; + vi.spyOn(server.services.notification, 'notify').mockImplementation( + async ( + userIds: number[], + payload: Record, + opts: { silent?: boolean } = {}, + ) => { + calls.push({ userIds, payload, silent: Boolean(opts.silent) }); + return 'stub-uid'; + }, + ); + return calls; + }; + + /** The share notifications actually on the recipient's list. */ + const openNotifications = async (userId: number) => { + const rows = await server.stores.notification.listByUserId(userId, { + filter: 'unacknowledged', + }); + return rows.filter( + (row: { value?: { template?: string } }) => + row.value?.template === 'file-shared-with-you', + ); + }; + + it('sends one notification per recipient, counting their items', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const bob = await makeUser(); + const calls = captureNotifications(); + + // Five items across two people is two notifications, not five — + // sharing a folder's worth of files must not become a mailstorm. + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice), + shareTo(alice), + shareTo(alice), + shareTo(bob), + shareTo(bob), + ]); + + expect(calls).toHaveLength(2); + const byUser = new Map(calls.map((c) => [c.userIds[0], c.payload])); + expect(byUser.get(alice.id)?.fields).toMatchObject({ count: 3 }); + expect(byUser.get(bob.id)?.fields).toMatchObject({ count: 2 }); + expect(byUser.get(alice.id)?.source).toBe('sharing'); + }); + + it('says "an item" for one and "items" for several', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const bob = await makeUser(); + const calls = captureNotifications(); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice), + shareTo(bob), + shareTo(bob), + ]); + + const who = sender.user.username; + const titles = calls.map((c) => String(c.payload.title)); + expect(titles).toContain(`${who} shared an item with you`); + expect(titles).toContain(`${who} shared 2 items with you`); + }); + + it('does not notify the sender about their own share', async () => { + const alice = await makeUser(); + const calls = captureNotifications(); + + await server.services.shareNotification.notifyShared(actorFor(alice), [ + shareTo(alice), + ]); + + expect(calls).toHaveLength(0); + }); + + it('stays quiet when nothing succeeded', async () => { + const sender = actorFor(await makeUser()); + const calls = captureNotifications(); + await server.services.shareNotification.notifyShared(sender, []); + expect(calls).toHaveLength(0); + }); + + it('swallows a delivery failure rather than surfacing it', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + vi.spyOn(server.services.notification, 'notify').mockRejectedValue( + new Error('notification backend down'), + ); + + // The share already landed by this point; reporting it as failed would + // be worse than the recipient not hearing about it. + await expect( + server.services.shareNotification.notifyShared(sender, [ + shareTo(alice), + ]), + ).resolves.toBeUndefined(); + }); + + it('stays quiet about a share that granted no new reach', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const calls = captureNotifications(); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice, { isNew: false }), + ]); + + expect(calls).toHaveLength(0); + }); + + + it('one recipient failing does not silence the rest', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const bob = await makeUser(); + + const reached: number[] = []; + vi.spyOn(server.services.notification, 'notify').mockImplementation( + async (userIds: number[]) => { + if (userIds[0] === alice.id) { + throw new Error('notification backend down for alice'); + } + reached.push(userIds[0]); + return 'stub-uid'; + }, + ); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice), + shareTo(bob), + ]); + + // Alice's failure is hers alone — bob still hears about his share. + expect(reached).toContain(bob.id); + }); + + it('interrupts a pair once per window, recording the rest silently', async () => { + const sender = actorFor(await makeUser()); + const alice = await makeUser(); + const calls = captureNotifications(); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice), + ]); + await server.services.shareNotification.notifyShared(sender, [ + shareTo(alice), + ]); + + // Both are recorded — a suppressed interruption must not lose the + // share — but only the first one interrupts. + expect(calls.map((call) => call.silent)).toEqual([false, true]); + }); + + it('folds another sender into a notification the recipient has not dealt with', async () => { + const alice = actorFor(await makeUser()); + const bob = actorFor(await makeUser()); + const holder = await makeUser(); + + await server.services.shareNotification.notifyShared(alice, [ + shareTo(holder), + ]); + await server.services.shareNotification.notifyShared(bob, [ + shareTo(holder), + shareTo(holder), + ]); + + // Two people sharing with you is one notification that counts them. + const open = await openNotifications(holder.id); + expect(open).toHaveLength(1); + expect(open[0].value.title).toBe( + `${alice.user.username} and ${bob.user.username} shared 3 items with you`, + ); + expect(open[0].value.fields.senders).toEqual([ + { username: alice.user.username, count: 1 }, + { username: bob.user.username, count: 2 }, + ]); + }); + + it('records a suppressed share on the open notification, undelivered', async () => { + const sender = actorFor(await makeUser()); + const holder = await makeUser(); + + await server.services.shareNotification.notifyShared(sender, [ + shareTo(holder), + ]); + const [first] = await openNotifications(holder.id); + await server.services.notification.markShown(first.uid, holder.id); + + // Same pair inside the window: no second interruption, but the count + // has to be right when the recipient looks. + await server.services.shareNotification.notifyShared(sender, [ + shareTo(holder), + ]); + + const [folded] = await openNotifications(holder.id); + expect(folded.uid).toBe(first.uid); + expect(folded.value.title).toBe( + `${sender.user.username} shared 2 items with you`, + ); + // Cleared, so the new wording goes out on the next connect — + // `#sendUnreads` only carries what was never shown. + expect(folded.shown).toBeNull(); + }); + + it('starts a fresh notification once the last one was dismissed', async () => { + const alice = actorFor(await makeUser()); + const bob = actorFor(await makeUser()); + const holder = await makeUser(); + + await server.services.shareNotification.notifyShared(alice, [ + shareTo(holder), + ]); + const [first] = await openNotifications(holder.id); + await server.services.notification.markAcknowledged( + first.uid, + holder.id, + ); + + await server.services.shareNotification.notifyShared(bob, [ + shareTo(holder), + ]); + + // Dismissed is dealt with; reviving it would put back something the + // recipient cleared, and it must not carry alice's count either. + const open = await openNotifications(holder.id); + expect(open).toHaveLength(1); + expect(open[0].uid).not.toBe(first.uid); + expect(open[0].value.title).toBe( + `${bob.user.username} shared an item with you`, + ); + }); +}); + +/** + * The budgets, at settings small enough to reach. Its own server: the limits are + * read from config, and the point is that they are. + */ +describe('ShareNotificationService budgets', () => { + let server: PuterServer; + + beforeAll(async () => { + server = await setupTestServer({ + share_notify_limits: { + pairWindowSeconds: 900, + pairDaily: 20, + recipientHourly: 2, + recipientDaily: 50, + }, + } as never); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + const makeUser = async () => { + const username = `sb${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'); + return user; + }; + + const actorFor = (user: { id: number; username: string }): Actor => + ({ + user: { id: user.id, username: user.username }, + effectiveApp: null, + }) as Actor; + + + /** + * User ids repeat across this file's two servers and ioredis-mock shares + * one keyspace per process, so budgets must start empty per test. + */ + const forgetBudgets = async (holderId: number, senderIds: number[]) => { + const keys = [ + `rate:share:notify:to:${holderId}`, + `rate:share:notify:to-day:${holderId}`, + ]; + for (const senderId of senderIds) { + keys.push( + `rate:share:notify:pair:${senderId}:${holderId}`, + `rate:share:notify:pair-day:${senderId}:${holderId}`, + ); + } + await server.clients.redis.del(...keys); + }; + + it('a recipient-level refusal does not spend the sender\'s day budget', async () => { + const holder = await makeUser(); + const first = actorFor(await makeUser()); + const second = actorFor(await makeUser()); + vi.spyOn(server.services.notification, 'notify').mockResolvedValue( + 'stub-uid', + ); + await forgetBudgets(holder.id, [first.user.id, second.user.id]); + + const shareFrom = (sender: Actor) => + server.services.shareNotification.notifyShared(sender, [ + { + uid: 'u', + mode: 'read', + path: '/somewhere/file.txt', + entryUid: 'e', + isDir: false, + issuer: { username: sender.user.username }, + holder: { username: holder.username }, + holderId: holder.id, + isNew: true, + createdAt: null, + modified: 0, + size: null, + } as ResolvedShare, + ]); + + // First sender saturates the recipient (recipientHourly is 2 here, + // minus what other tests spent — drain it deterministically). + await shareFrom(first); + await shareFrom(actorFor(await makeUser())); + // Second sender's first-ever contact lands on a saturated recipient. + await shareFrom(second); + + // Refused by the recipient's ceiling — but the sender's day-scale + // budget must not have paid for an interruption that never happened, + // or twenty such refusals mute their first real contact all day. + const secondId = second.user.id; + const pairDay = await server.clients.redis.zcard( + `rate:share:notify:pair-day:${secondId}:${holder.id}`, + ); + expect(pairDay).toBe(0); + // The pair window did record — it is checked first, and what it burns + // expires in minutes. + const pairWindow = await server.clients.redis.zcard( + `rate:share:notify:pair:${secondId}:${holder.id}`, + ); + expect(pairWindow).toBe(1); + vi.restoreAllMocks(); + }); + + it('stops interrupting a recipient once their own budget is spent, whoever is sharing', async () => { + const holder = await makeUser(); + const senders = [ + actorFor(await makeUser()), + actorFor(await makeUser()), + actorFor(await makeUser()), + ]; + await forgetBudgets( + holder.id, + senders.map((sender) => sender.user.id as number), + ); + const silent: boolean[] = []; + vi.spyOn(server.services.notification, 'notify').mockImplementation( + async ( + _userIds: number[], + _payload: Record, + opts: { silent?: boolean } = {}, + ) => { + silent.push(Boolean(opts.silent)); + return 'stub-uid'; + }, + ); + + for (const sender of senders) { + await server.services.shareNotification.notifyShared(sender, [ + { + uid: 'u', + mode: 'read', + path: '/somewhere/file.txt', + entryUid: 'e', + isDir: false, + issuer: { username: sender.user.username }, + holder: { username: holder.username }, + holderId: holder.id, + isNew: true, + createdAt: null, + modified: 0, + size: null, + } as ResolvedShare, + ]); + } + + // Three different senders, each with an untouched pair budget — what + // stops the third is the recipient's own hourly ceiling. + expect(silent).toEqual([false, false, true]); + vi.restoreAllMocks(); + }); +}); diff --git a/src/backend/services/share/ShareNotificationService.ts b/src/backend/services/share/ShareNotificationService.ts new file mode 100644 index 000000000..b8e55d79a --- /dev/null +++ b/src/backend/services/share/ShareNotificationService.ts @@ -0,0 +1,870 @@ +/* + * 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 { createHash, randomUUID } from 'node:crypto'; +import { checkRateLimit } from '../../core/http/middleware/rateLimit.js'; +import type { Actor } from '../../core/actor'; +import type { LayerInstances } from '../../types'; +import type { puterServices } from '../index'; +import { PuterService } from '../types'; +import { + digestLines, + digestSubject, + mergeDigestEntry, + mergeShareSender, + shareNotifyCount, + shareNotifyTitle, + shareSendersFromFields, + type DigestEntry, + type ShareSender, +} from './shareNotifyTitle'; +import type { ResolvedShare } from './ShareService'; + +/** + * How long one sharer stays quiet after reaching a recipient, and how long a + * recipient's notification keeps absorbing new shares. + */ +export const SHARE_NOTIFY_WINDOW_SECONDS = 15 * 60; + +/** Times one sharer may interrupt the same recipient in a day. */ +export const SHARE_NOTIFY_PAIR_DAILY_LIMIT = 20; + +/** Times a recipient may be interrupted per hour, whoever it is from. */ +export const SHARE_NOTIFY_RECIPIENT_HOURLY_LIMIT = 10; + +/** Same, over a day. */ +export const SHARE_NOTIFY_RECIPIENT_DAILY_LIMIT = 50; + +/** + * How long emails to one recipient are held so that everything triggered in the + * span goes as a single message. Email can't be rewritten the way the in-app + * notification can, so it gets the grouped wording by waiting instead. + */ +export const SHARE_EMAIL_BATCH_SECONDS = 90; + +// Long enough to list, claim and send; short enough that a crashed flusher +// doesn't strand the digest. +const DIGEST_LOCK_SECONDS = 30; + +// Ceiling on how long an entry may sit unflushed: a node dying with the only +// timer leaves entries behind, and mail this stale is noise rather than news. +const DIGEST_ENTRY_TTL_SECONDS = 24 * 60 * 60; + +/** + * How long past its window an entry must sit before the sweep treats it as + * orphaned. + * + * The sweep exists for entries whose timer died with the node that armed it, + * and only the owning node can tell the difference between that and a timer + * about to fire. Claiming an entry is exclusive only among flushers that can + * see each other's deletes, so a sweep racing a live timer elsewhere can send + * the same digest twice. Waiting gives the owner first refusal, and costs a + * genuinely stranded digest only this much delay. + */ +const DIGEST_SWEEP_GRACE_MS = 5 * 60_000; + +/** + * Entries one listing carries. A recipient over this in a single window has the + * rest picked up by the next pass rather than dropped, but the cap is logged + * either way — a silent truncation here reads as "nothing left to send". + */ +const DIGEST_LIST_LIMIT = 200; + +/** Names carried per sender; the wording counts the rest. */ +const DIGEST_NAMES_PER_SENDER = 5; + +/** How far back to look for the notification a new share folds into. */ +const OPEN_NOTIFICATION_SCAN = 20; + +const HOUR_MS = 60 * 60_000; +const DAY_MS = 24 * HOUR_MS; + +/** Notifications this service folds shares into. */ +const SHARE_TEMPLATE = 'file-shared-with-you'; + +/** Kept distinct from `SHARE_TEMPLATE` so grouping never rewrites it. */ +const CLAIM_TEMPLATE = 'file-shared-before-you-joined'; + +/** A budget: a key, how many are allowed, and over what window. */ +type Budget = [key: string, limit: number, windowMs: number]; + +/** + * Why a recipient heard nothing. Every gate on this path is a deliberate early + * return, so without a line each they are indistinguishable from a lost email + * once it's running somewhere you can't attach a debugger to. + */ +const skipped = (reason: string, detail: Record): void => { + console.log('[share-notify] not emailing:', reason, detail); +}; + +/** + * Telling people what has been shared with them. Separate from `ShareService` + * because sharing succeeds or fails on its own; being told is best-effort and + * always off the response path. + * + * Two decisions per share: what the recipient's notification _says_ is always + * kept current, while whether it may _interrupt_ them — pushed to their screen, + * mailed to them — is budgeted, since that is the part that can bury someone. + */ +/** + * A queued send's durable form: persisted to KV so it survives the node that + * queued it and is visible to every other node's flush. + */ +interface DigestEntryRecord { + kind: 'holder' | 'invite'; + to: string; + /** Holder's username, for the greeting. Absent for invites. */ + recipient?: string; + /** Holder's uuid, so the digest can carry their unsubscribe link. */ + recipientUuid?: string; + sender?: string; + count: number; + names: string[]; + /** Arrival order — KV lists by key, which is a uuid and says nothing. */ + queuedAt: number; +} + +export class ShareNotificationService extends PuterService { + declare protected services: LayerInstances; + + /** + * The flush timers this node owns, keyed per recipient. Timers only — the + * queued sends live in KV, where any node's flush can pick them up. + */ + #digestTimers = new Map>(); + + /** The recovery sweep; see `onServerStart`. */ + #digestSweep: ReturnType | null = null; + + /** + * Recover digests whose timer died with the node that armed it — a restart, + * a rolling deploy, or a SIGKILL that never reached the drain below. The + * entries are in KV, so any node can finish them; without this sweep they + * would sit there until their TTL and nobody would ever be told. + */ + override onServerStart(): void { + const every = Math.max(30, this.#limits().emailBatchSeconds) * 1000; + const sweep = setInterval(() => { + void this.#sweepDigests(); + }, every); + sweep.unref?.(); + this.#digestSweep = sweep; + } + + /** Send what's still waiting while the transport is alive to send it. */ + override async onServerPrepareShutdown(): Promise { + if (this.#digestSweep) clearInterval(this.#digestSweep); + this.#digestSweep = null; + const keys = [...this.#digestTimers.keys()]; + for (const [, timer] of this.#digestTimers) clearTimeout(timer); + this.#digestTimers.clear(); + await Promise.all(keys.map((key) => this.#flushDigest(key))); + } + + /** The recovery sweep, for tests that can't wait out an interval. */ + async sweepForTests(): Promise { + await this.#sweepDigests(); + } + + /** + * Flush every digest whose window has elapsed and which no node here is + * still holding a timer for. Cheap: one prefix listing, and the lock plus + * `take` make a premature or duplicated sweep harmless. + */ + async #sweepDigests(): Promise { + try { + const { res } = await this.stores.kv.list({ + as: 'entries', + pattern: 'share:digest:', + limit: DIGEST_LIST_LIMIT, + }); + const listed = ( + Array.isArray(res) + ? res + : ((res as { items?: unknown[] })?.items ?? []) + ) as Array<{ key: string; value: unknown }>; + if (listed.length === 0) return; + if (listed.length >= DIGEST_LIST_LIMIT) { + console.log('[share-notify] sweep listing hit its cap:', { + limit: DIGEST_LIST_LIMIT, + }); + } + + const staleAfterMs = + this.#limits().emailBatchSeconds * 1000 + DIGEST_SWEEP_GRACE_MS; + const due = new Set(); + for (const entry of listed) { + const key = entry.key.slice( + 'share:digest:'.length, + entry.key.lastIndexOf(':'), + ); + if (!key || this.#digestTimers.has(key)) continue; + const queuedAt = Number( + (entry.value as DigestEntryRecord | null)?.queuedAt ?? 0, + ); + if (Date.now() - queuedAt < staleAfterMs) continue; + due.add(key); + } + if (due.size === 0) return; + + console.log('[share-notify] sweeping orphaned digests:', { + count: due.size, + }); + for (const key of due) await this.#flushDigest(key); + } catch (err) { + console.warn('[share-notify] digest sweep failed:', err); + } + } + + /** + * Announce a batch of shares, one notification per recipient — five items + * for one person is one notification, not five. Never throws: a share that + * landed must not be reported as failed because the announcement wasn't. + * + * Only shares that created new reach count; a mode change is not worth + * interrupting anyone for. + */ + async notifyShared(actor: Actor, shares: ResolvedShare[]): Promise { + const issuerId = actor.user?.id; + const issuer = actor.user?.username; + if (typeof issuerId !== 'number') return; + + const counts = new Map(); + const named = new Map(); + for (const share of shares) { + if (share.pending) continue; + if (!share.isNew || !share.holderId) continue; + if (share.holderId === issuerId) continue; + counts.set(share.holderId, (counts.get(share.holderId) ?? 0) + 1); + if (share.name) { + const names = named.get(share.holderId) ?? []; + if (names.length < DIGEST_NAMES_PER_SENDER) { + names.push(share.name); + } + named.set(share.holderId, names); + } + } + + // Each recipient fails alone: one refused send must not cost the next + // person their notification. Failures are logged, never thrown. + await Promise.allSettled( + [...counts].map(async ([holderId, count]) => { + try { + const interrupt = await this.#claimInterruption( + issuerId, + holderId, + ); + await this.#announce(holderId, issuer, count, interrupt); + await this.#emailHolder( + holderId, + issuer, + count, + named.get(holderId) ?? [], + interrupt, + ); + } catch (err) { + console.warn( + '[share-notify] could not announce to user', + holderId, + err, + ); + } + }), + ); + + try { + await this.#emailInvites(actor, shares); + } catch (err) { + console.warn('[share-notify] could not email invites:', err); + } + } + + /** + * Fold this batch into the notification the recipient hasn't dealt with, or + * start one. Written either way — a suppressed interruption must not lose + * the share — so `interrupt` gates only the push. + */ + async #announce( + holderId: number, + issuer: string | undefined, + count: number, + interrupt: boolean, + ): Promise { + const silent = !interrupt; + const open = await this.#openShareNotification(holderId); + + if (open) { + const folded = this.#payload( + issuer, + mergeShareSender(open.senders, issuer, count), + open.groupUntil, + ); + if ( + await this.services.notification.notifyUpdate( + open.uid, + holderId, + folded, + { silent }, + ) + ) { + return; + } + // Dismissed between the read and the write — fall through and start + // a fresh group rather than reviving what they just cleared. + } + + await this.services.notification.notify( + [holderId], + this.#payload( + issuer, + mergeShareSender([], issuer, count), + Date.now() + this.#limits().pairWindowSeconds * 1000, + ), + { silent }, + ); + } + + /** + * `groupUntil` is when this notification stops absorbing shares. Carried on + * the payload rather than derived from `created_at`, whose type differs per + * dialect, and kept as the group grows so a busy hour still closes it on + * schedule. + */ + #payload( + issuer: string | undefined, + senders: ShareSender[], + groupUntil: number, + ): Record { + return { + source: 'sharing', + title: shareNotifyTitle(senders), + template: SHARE_TEMPLATE, + // `username` and `count` are the pre-grouping shape, kept for + // anything still reading it; `senders` is what the title is from. + fields: { + username: issuer, + count: shareNotifyCount(senders), + senders, + groupUntil, + }, + }; + } + + /** The share notification still open for more, if there is one. */ + async #openShareNotification(holderId: number): Promise<{ + uid: string; + senders: ShareSender[]; + groupUntil: number; + } | null> { + const rows = await this.stores.notification.listByUserId(holderId, { + filter: 'unacknowledged', + limit: OPEN_NOTIFICATION_SCAN, + }); + + for (const row of rows) { + const value = (row?.value ?? {}) as { + template?: unknown; + fields?: { groupUntil?: unknown }; + }; + if (value.template !== SHARE_TEMPLATE || !row?.uid) continue; + + // Newest first, so the first share notification is the only + // candidate: if its group has closed, every older one's has too. + const groupUntil = Number(value.fields?.groupUntil); + if (!Number.isFinite(groupUntil) || groupUntil <= Date.now()) { + return null; + } + return { + uid: String(row.uid), + senders: shareSendersFromFields(value.fields), + groupUntil, + }; + } + return null; + } + + /** + * Whether this share may interrupt the recipient at all. The pair budget + * stops one person nagging; the recipient budget bounds the total noise. + * Fails open — one notification too many beats going silent. + * + * Order matters because `checkRateLimit` records on allow with no refund: + * budgets after the refusing one stay unspent. The pair window leads (it + * refuses most, and its token self-expires in minutes); pair-day goes last + * so a saturated recipient can't burn a sender's day-scale budget. + */ + async #claimInterruption( + issuerId: number, + holderId: number, + ): Promise { + const limits = this.#limits(); + return this.#withinBudget([ + [ + `share:notify:pair:${issuerId}:${holderId}`, + 1, + limits.pairWindowSeconds * 1000, + ], + [`share:notify:to:${holderId}`, limits.recipientHourly, HOUR_MS], + [`share:notify:to-day:${holderId}`, limits.recipientDaily, DAY_MS], + [ + `share:notify:pair-day:${issuerId}:${holderId}`, + limits.pairDaily, + DAY_MS, + ], + ]); + } + + /** + * The same question for an address with no account, in the same order for + * the same reasons. Keyed on the canonical form, so `foo+1@` and `foo+2@` + * can't each buy a budget, and hashed to keep addresses out of cache keys. + */ + async #claimInviteEmail(issuerId: number, email: string): Promise { + const limits = this.#limits(); + const to = this.#addressKey(email); + return this.#withinBudget([ + [ + `share:notify:invite:${issuerId}:${to}`, + 1, + limits.pairWindowSeconds * 1000, + ], + [`share:notify:to-addr:${to}`, limits.recipientHourly, HOUR_MS], + [`share:notify:to-addr-day:${to}`, limits.recipientDaily, DAY_MS], + [ + `share:notify:invite-day:${issuerId}:${to}`, + limits.pairDaily, + DAY_MS, + ], + ]); + } + + /** All bounds hold. A non-positive limit removes its bound, as elsewhere. */ + async #withinBudget(budgets: Budget[]): Promise { + for (const [key, limit, windowMs] of budgets) { + if (limit <= 0) continue; + if (!(await checkRateLimit(key, limit, windowMs))) return false; + } + return true; + } + + #addressKey(email: string): string { + const canonical = this.clients.email.clean(email.trim().toLowerCase()); + return createHash('sha256') + .update(canonical) + .digest('hex') + .slice(0, 32); + } + + #limits(): { + pairWindowSeconds: number; + pairDaily: number; + recipientHourly: number; + recipientDaily: number; + emailBatchSeconds: number; + } { + const configured = this.config.share_notify_limits ?? {}; + return { + pairWindowSeconds: + configured.pairWindowSeconds ?? SHARE_NOTIFY_WINDOW_SECONDS, + pairDaily: configured.pairDaily ?? SHARE_NOTIFY_PAIR_DAILY_LIMIT, + recipientHourly: + configured.recipientHourly ?? + SHARE_NOTIFY_RECIPIENT_HOURLY_LIMIT, + recipientDaily: + configured.recipientDaily ?? SHARE_NOTIFY_RECIPIENT_DAILY_LIMIT, + emailBatchSeconds: + configured.emailBatchSeconds ?? SHARE_EMAIL_BATCH_SECONDS, + }; + } + + /** + * Email a recipient who already has an account. On unless configuration + * says otherwise: they decline with the unsubscribe link the mail carries, + * or by blocking the sender. + */ + async #emailHolder( + holderId: number, + issuer: string | undefined, + count: number, + itemNames: string[], + mayOpen: boolean, + ): Promise { + // Explicitly false, not falsy: unset means on. + if (this.config.share_email_notifications === false) { + skipped('share_email_notifications is off', { holderId }); + return; + } + if (!this.config.email) { + skipped('no email transport configured', { holderId }); + return; + } + + const holder = await this.stores.user.getById(holderId); + const to = holder?.email; + if (!to || !holder?.email_confirmed) { + skipped('recipient has no confirmed address', { + holderId, + hasAddress: Boolean(to), + confirmed: Boolean(holder?.email_confirmed), + }); + return; + } + // The account-wide opt-out every other transactional sender honors. + if (holder.unsubscribed) { + skipped('recipient has unsubscribed', { holderId }); + return; + } + if (!(await this.clients.email.validate(to))) { + skipped('address refused by validate', { holderId }); + return; + } + + await this.#queueDigest( + `user:${holderId}`, + { + kind: 'holder', + to, + recipient: holder.username, + recipientUuid: holder.uuid, + }, + issuer, + count, + itemNames, + mayOpen, + ); + } + + /** + * Queue a send into the recipient's digest and arm the window. The entry + * goes to durable KV first, so nothing rides on this process surviving; the + * timer is only the alarm clock, and the flush arbitrates who sends. + */ + async #queueDigest( + key: string, + seed: Pick< + DigestEntryRecord, + 'kind' | 'to' | 'recipient' | 'recipientUuid' + >, + sender: string | undefined, + count: number, + names: string[], + mayOpen: boolean, + ): Promise { + // A digest is one email, so the budget is spent opening one, not per + // share — anything arriving while one collects joins it for free. + if (!mayOpen && !(await this.#digestIsOpen(key))) { + skipped('interruption budget spent, no digest open', { key }); + return; + } + + const record: DigestEntryRecord = { + ...seed, + sender, + count, + names, + queuedAt: Date.now(), + }; + await this.stores.kv.set({ + key: `share:digest:${key}:${randomUUID()}`, + value: record as unknown as Record, + expireAt: Math.floor(Date.now() / 1000) + DIGEST_ENTRY_TTL_SECONDS, + }); + + if (this.#digestTimers.has(key)) { + console.log('[share-notify] queued into an open digest:', { key }); + return; + } + const seconds = this.#limits().emailBatchSeconds; + if (seconds > 0) { + console.log('[share-notify] digest window opened:', { + key, + seconds, + }); + const timer = setTimeout(() => { + this.#digestTimers.delete(key); + void this.#flushDigest(key); + }, seconds * 1000); + // A pending email must not keep the process alive on its own; + // shutdown drains the timers explicitly. + timer.unref?.(); + this.#digestTimers.set(key, timer); + } else { + await this.#flushDigest(key); + } + } + + /** + * Whether a digest is already collecting for this recipient. Falls back to + * KV, since another node or region may have opened it. + */ + async #digestIsOpen(key: string): Promise { + if (this.#digestTimers.has(key)) return true; + try { + const { res } = await this.stores.kv.list({ + as: 'keys', + pattern: `share:digest:${key}:`, + limit: 1, + }); + const listed = Array.isArray(res) + ? res + : ((res as { items?: unknown[] })?.items ?? []); + return listed.length > 0; + } catch { + // Fail closed: a broken read must not become unlimited joins. + return false; + } + } + + /** + * Send one recipient's digest: everything queued for them, from any node, + * as one message. Two arbiters keep it to one email: the region-local Redis + * lock stops same-region stampedes (losers just leave — the winner sends + * everything queued), and across regions the KV `take` is the real claim, + * handing each entry to exactly one flusher. + */ + async #flushDigest(key: string): Promise { + const lockKey = `share:digest:lock:${key}`; + try { + const locked = await this.clients.redis.set( + lockKey, + '1', + 'EX', + DIGEST_LOCK_SECONDS, + 'NX', + ); + if (locked !== 'OK') { + console.log('[share-notify] another flush holds the lock:', { + key, + }); + return; + } + } catch { + // No lock beats no mail; `take` still prevents double sends. + } + + try { + const prefix = `share:digest:${key}:`; + const { res } = await this.stores.kv.list({ + as: 'keys', + pattern: prefix, + limit: DIGEST_LIST_LIMIT, + }); + // `list` answers with a paged envelope; unwrap defensively. + const listed = Array.isArray(res) + ? res + : ((res as { items?: unknown[] })?.items ?? []); + const keys = listed.filter( + (entry): entry is string => typeof entry === 'string', + ); + if (keys.length >= DIGEST_LIST_LIMIT) { + // The rest keep their TTL and go in a later flush; say so, + // because the digest that goes out now undercounts. + console.log('[share-notify] digest listing hit its cap:', { + key, + limit: DIGEST_LIST_LIMIT, + }); + } + + const claimed: Array<{ key: string; record: DigestEntryRecord }> = + []; + for (const entryKey of keys) { + const taken = await this.stores.kv.take({ key: entryKey }); + if (taken.res == null) continue; // another flush won this one + claimed.push({ + key: entryKey, + record: taken.res as DigestEntryRecord, + }); + } + if (claimed.length === 0) { + // Listed nothing, or every entry went to another flusher. + console.log('[share-notify] nothing to send:', { + key, + listed: keys.length, + }); + return; + } + + // Arrival order, so "alice and bob" reads in the order they + // actually shared rather than however the keys happened to sort. + claimed.sort( + (a, b) => (a.record.queuedAt ?? 0) - (b.record.queuedAt ?? 0), + ); + let entries: DigestEntry[] = []; + for (const { record } of claimed) { + entries = mergeDigestEntry( + entries, + record.sender, + record.count, + record.names ?? [], + ); + } + const [{ record: first }] = claimed; + console.log('[share-notify] sending digest:', { + key, + kind: first.kind, + to: first.to, + entries: claimed.length, + senders: entries.length, + }); + + try { + if (first.kind === 'holder') { + await this.clients.email.send( + first.to, + 'file_shared_with_you', + { + recipient: first.recipient, + subject_line: digestSubject(entries), + shares: digestLines(entries), + link: this.#appLink(), + // The template composes the URL, so `?` and `=` + // stay literal instead of escaping to `=`. + unsubscribe_uuid: first.recipientUuid ?? null, + }, + ); + } else { + await this.clients.email.send( + first.to, + 'file_shared_invite', + { + email: first.to, + subject_line: digestSubject(entries, { + suffix: 'on Puter', + }), + shares: digestLines(entries), + link: this.#appLink(), + }, + ); + } + console.log('[share-notify] digest sent:', { + key, + to: first.to, + }); + } catch (err) { + // The entries are claimed but unsent — put them back so a + // later flush retries, instead of losing the notification. + console.warn('[share-notify] digest email failed:', err); + await Promise.allSettled( + claimed.map(({ key: entryKey, record }) => + this.stores.kv.set({ + key: entryKey, + value: record as unknown as Record, + expireAt: + Math.floor(Date.now() / 1000) + + DIGEST_ENTRY_TTL_SECONDS, + }), + ), + ); + } + } catch (err) { + console.warn('[share-notify] digest flush failed:', err); + } finally { + try { + await this.clients.redis.del(lockKey); + } catch { + // The lock self-expires. + } + } + } + + /** + * Email an address with no account. Sent whatever + * `share_email_notifications` says — there is no Puter inbox to use instead + * — but still budgeted: an invite reaches someone who never asked for it. + */ + async #emailInvites(actor: Actor, shares: ResolvedShare[]): Promise { + if (!this.config.email) return; + const issuerId = actor.user?.id; + if (typeof issuerId !== 'number') return; + + const byEmail = new Map(); + for (const share of shares) { + if (!share.pending || !share.isNew || !share.recipientEmail) { + continue; + } + const seen = byEmail.get(share.recipientEmail) ?? { + count: 0, + names: [], + }; + seen.count += 1; + if (share.name && seen.names.length < DIGEST_NAMES_PER_SENDER) { + seen.names.push(share.name); + } + byEmail.set(share.recipientEmail, seen); + } + if (byEmail.size === 0) return; + + const issuer = actor.user?.username; + for (const [to, { count, names }] of byEmail) { + // Each address fails alone — one refused send must not cost the + // next invitee their only channel. + try { + if (!(await this.clients.email.validate(to))) { + skipped('invite address refused by validate', { to }); + continue; + } + const mayOpen = await this.#claimInviteEmail(issuerId, to); + await this.#queueDigest( + `invite:${to}`, + { kind: 'invite', to }, + issuer, + count, + names, + mayOpen, + ); + } catch (err) { + console.warn('[share-notify] invite email failed:', err); + } + } + } + + /** + * What was waiting once they confirmed their address — one notification for + * the lot, since several people may have shared with it. + */ + async notifyClaimed( + holderId: number, + shares: ResolvedShare[], + ): Promise { + if (shares.length === 0) return; + try { + const count = shares.length; + await this.services.notification.notify([holderId], { + source: 'sharing', + title: `${count === 1 ? 'An item was' : `${count} items were`} shared with you before you joined`, + template: CLAIM_TEMPLATE, + fields: { count }, + }); + } catch { + // Best-effort by design; see the class comment. + } + } + + /** + * `config.origin` is what every other email link uses, and it carries the + * port — re-deriving from protocol and domain sent self-hosters' "Open it + * on Puter" links to an address nothing answers on. + */ + #appLink(): string { + return ( + this.config.origin ?? + `${this.config.protocol ?? 'http'}://${this.config.domain ?? 'puter.com'}` + ); + } +} diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index 50f75c0ef..bb137538e 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -41,8 +41,11 @@ describe('ShareService', () => { const user = await server.stores.user.getByUsername(username); if (!user) throw new Error('test user missing'); const email = `${username}@test.local`; + // clean_email too, as real signup writes it — canonical resolution + // (alias and case variants) rides on that column. await server.stores.user.update(user.id, { email, + clean_email: email, email_confirmed: true, }); const fresh = await server.stores.user.getById(user.id, { @@ -139,6 +142,7 @@ describe('ShareService', () => { expect(result.mode).toBe('read'); expect(result.path).toBe(file.path); + expect(result.name).toBe(file.name); expect(await canRead(recipient.actor, file.path)).toBe(true); const listed = await server.services.share.listSharedWithMe( @@ -193,18 +197,10 @@ describe('ShareService', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); - it('refuses an unknown mode and an unknown recipient', async () => { + it('refuses an unknown mode', 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, @@ -214,7 +210,7 @@ describe('ShareService', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); - it('does not resolve an email its account has not confirmed', async () => { + it('grants nothing to an email its account has not confirmed', async () => { const owner = await makeUser(); const squatter = await makeUser(); await server.stores.user.update(squatter.user.id, { @@ -222,16 +218,15 @@ describe('ShareService', () => { }); 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', + // The address is a claim, not an identity: the share waits as an + // invite rather than handing access to whoever registered it first. + const result = await share(owner.actor, { + uid: file.uuid, + recipient: { email: squatter.email }, + mode: 'read', }); + expect(result.pending).toBe(true); + expect(await canRead(squatter.actor, file.path)).toBe(false); // A username names exactly one account, confirmed or not. await share(owner.actor, { @@ -239,6 +234,7 @@ describe('ShareService', () => { recipient: { username: squatter.user.username }, mode: 'read', }); + expect(await canRead(squatter.actor, file.path)).toBe(true); }); it('hides a file from a stranger trying to share it', async () => { @@ -1016,18 +1012,22 @@ describe('ShareService', () => { ).toEqual([]); }); - it('notifies a recipient once per window, not once per re-share', async () => { + it('interrupts 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 notified: Array<{ ids: number[]; silent: boolean }> = []; const notify = server.services.notification.notify.bind( server.services.notification, ); - server.services.notification.notify = (async (ids: number[]) => { - notified.push(ids); + server.services.notification.notify = (async ( + ids: number[], + _payload: unknown, + opts: { silent?: boolean } = {}, + ) => { + notified.push({ ids, silent: Boolean(opts.silent) }); }) as never; try { const shared = await share(owner.actor, { @@ -1035,7 +1035,9 @@ describe('ShareService', () => { recipient: { username: recipient.user.username }, mode: 'read', }); - await server.services.share.notifyRecipients(owner.actor, [shared]); + await server.services.shareNotification.notifyShared(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. @@ -1049,7 +1051,7 @@ describe('ShareService', () => { recipient: { username: recipient.user.username }, mode: 'read', }); - await server.services.share.notifyRecipients(owner.actor, [ + await server.services.shareNotification.notifyShared(owner.actor, [ again, other, ]); @@ -1057,7 +1059,14 @@ describe('ShareService', () => { server.services.notification.notify = notify; } - expect(notified).toEqual([[recipient.user.id]]); + // The second batch is still recorded — the recipient must not open + // Puter to a notification that undercounts what is waiting — but it + // arrives silently, without a second interruption. + expect(notified.map((call) => call.ids)).toEqual([ + [recipient.user.id], + [recipient.user.id], + ]); + expect(notified.map((call) => call.silent)).toEqual([false, true]); }); describe('an app is bounded by what it was given', () => { @@ -1594,4 +1603,818 @@ describe('ShareService', () => { expect(await canRead(recipient.actor, file.path)).toBe(false); } }); + + describe('an address with no account yet', () => { + const pendingEmail = () => + `pending-${Math.random().toString(36).slice(2, 9)}@test.local`; + + it('records an invite instead of refusing the share', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = pendingEmail(); + + const result = await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + expect(result.pending).toBe(true); + expect(result.recipientEmail).toBe(email); + expect(result.holder.username).toBeNull(); + + const rows = await server.stores.share.listPendingByEmail(email); + expect(rows).toHaveLength(1); + expect(Number(rows[0].fsentry_id)).toBe(file.id); + expect(rows[0].holder_user_id).toBeNull(); + }); + + it('still refuses a username that does not exist', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { username: 'nobody-by-that-name' }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 404 }); + }); + + it('does not pile up a row per re-invite', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = pendingEmail(); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + const again = await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'write', + }); + + const rows = await server.stores.share.listPendingByEmail(email); + expect(rows).toHaveLength(1); + expect(rows[0].mode).toBe('write'); + expect(again.isNew).toBe(false); + }); + + it('grants nothing until the address is confirmed', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = pendingEmail(); + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + const claimer = await makeUser(); + expect(await canRead(claimer.actor, file.path)).toBe(false); + }); + + it('becomes a real grant when the address is confirmed', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = pendingEmail(); + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + const claimer = await makeUser(); + await server.stores.user.update(claimer.user.id, { email }); + const claimed = await server.services.share.claimPendingShares( + claimer.user.id, + email, + ); + + expect(claimed).toHaveLength(1); + expect(await canRead(claimer.actor, file.path)).toBe(true); + + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + const listed = await server.services.share.listSharedWithMe( + claimer.actor, + ); + expect(listed.items).toHaveLength(1); + }); + + it('drops an invite whose issuer can no longer share it', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = pendingEmail(); + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + await server.clients.db.write( + 'DELETE FROM `fsentries` WHERE `id` = ?', + [file.id], + ); + + const claimer = await makeUser(); + const claimed = await server.services.share.claimPendingShares( + claimer.user.id, + email, + ); + + expect(claimed).toEqual([]); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + }); + + it('withdraws an invite before it is claimed', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = pendingEmail(); + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + const result = await unshare(owner.actor, { + uid: file.uuid, + recipient: { email }, + }); + + expect(result.revoked).toBe(1); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + + const claimer = await makeUser(); + expect( + await server.services.share.claimPendingShares( + claimer.user.id, + email, + ), + ).toEqual([]); + }); + + it('never grants an invite back to the node owner', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = pendingEmail(); + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + const claimed = await server.services.share.claimPendingShares( + owner.user.id, + email, + ); + + expect(claimed).toEqual([]); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + }); + }); + + describe('blocking a sender', () => { + const blockEmail = () => + `blocked-${Math.random().toString(36).slice(2, 9)}@test.local`; + + it('refuses the share, and grants nothing', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await server.services.share.blockSender( + recipient.actor, + owner.user.username, + ); + + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'recipient_not_accepting_shares', + }); + + expect(await canRead(recipient.actor, file.path)).toBe(false); + expect(await server.stores.share.listByFsentry(file.id)).toEqual( + [], + ); + }); + + it('costs the blocked sender none of their daily quota', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + await server.services.share.blockSender( + recipient.actor, + owner.user.username, + ); + + // Reading the counter by incrementing it with nothing. + const before = await server.stores.share.incrementDailyShareCount( + owner.user.id, + 0, + ); + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }), + ).rejects.toMatchObject({ statusCode: 403 }); + + // A refused share is not reach handed out, so it buys nothing and + // costs nothing — otherwise blocking one recipient would eat the + // sender's budget for everyone else. + expect( + await server.stores.share.incrementDailyShareCount( + owner.user.id, + 0, + ), + ).toBe(before); + }); + + it('accepts shares again once the block is lifted', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await server.services.share.blockSender( + recipient.actor, + owner.user.username, + ); + expect( + await server.services.share.unblockSender( + recipient.actor, + owner.user.username, + ), + ).toMatchObject({ unblocked: true }); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('leaves access the sender already granted alone', 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', + }); + await server.services.share.blockSender( + recipient.actor, + owner.user.username, + ); + + // Blocking stops what comes next. What they already have is theirs + // until it is revoked — a control labelled "block" silently + // withdrawing it would be a surprise. + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('is idempotent, and refuses to block yourself', async () => { + const recipient = await makeUser(); + const sender = await makeUser(); + + expect( + await server.services.share.blockSender( + recipient.actor, + sender.user.username, + ), + ).toMatchObject({ created: true }); + expect( + await server.services.share.blockSender( + recipient.actor, + sender.user.username, + ), + ).toMatchObject({ created: false }); + + await expect( + server.services.share.blockSender( + recipient.actor, + recipient.user.username, + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'cannot_block_self', + }); + }); + + it('lists who the caller blocked, and nothing internal', async () => { + const recipient = await makeUser(); + const first = await makeUser(); + const second = await makeUser(); + + await server.services.share.blockSender( + recipient.actor, + first.user.username, + ); + await server.services.share.blockSender( + recipient.actor, + second.user.username, + ); + + const listed = await server.services.share.listBlockedSenders( + recipient.actor, + ); + // Most recent first, and named by username only. + expect(listed.items.map((row) => row.username)).toEqual([ + second.user.username, + first.user.username, + ]); + for (const row of listed.items) { + expect(Object.keys(row).sort()).toEqual([ + 'createdAt', + 'username', + ]); + } + expect(listed.all).toBe(false); + // Someone else's blocklist is not the caller's. + expect( + await server.services.share.listBlockedSenders(first.actor), + ).toEqual({ all: false, items: [] }); + }); + + it('refuses to block an account that does not exist', async () => { + const recipient = await makeUser(); + await expect( + server.services.share.blockSender(recipient.actor, 'nobody-x'), + ).rejects.toMatchObject({ + statusCode: 404, + legacyCode: 'user_does_not_exist', + }); + }); + + it('refuses every sender once the blanket switch is on', async () => { + const recipient = await makeUser(); + const first = await makeUser(); + const second = await makeUser(); + const fileOne = await makeFile(first.user); + const fileTwo = await makeFile(second.user); + + await server.services.share.setBlockAllSenders( + recipient.actor, + true, + ); + + // Nobody is on the per-sender list, so this can only be the + // blanket switch answering. + for (const [sender, file] of [ + [first, fileOne], + [second, fileTwo], + ] as const) { + await expect( + share(sender.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'recipient_not_accepting_shares', + }); + } + expect(await canRead(recipient.actor, fileOne.path)).toBe(false); + }); + + it('reports the same code whether it is everyone or one person', async () => { + const recipient = await makeUser(); + const sender = await makeUser(); + const file = await makeFile(sender.user); + + // Which of the two it is is the recipient's business — a sender + // who could tell them apart would learn they were singled out. + await server.services.share.setBlockAllSenders( + recipient.actor, + true, + ); + const blanket = await share(sender.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }).catch((err) => err); + + await server.services.share.setBlockAllSenders( + recipient.actor, + false, + ); + await server.services.share.blockSender( + recipient.actor, + sender.user.username, + ); + const personal = await share(sender.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }).catch((err) => err); + + expect(personal.statusCode).toBe(blanket.statusCode); + expect(personal.legacyCode).toBe(blanket.legacyCode); + expect(personal.message).toBe(blanket.message); + }); + + it('keeps the per-sender list intact while it is on', async () => { + const recipient = await makeUser(); + const sender = await makeUser(); + const other = await makeUser(); + const file = await makeFile(other.user); + + await server.services.share.blockSender( + recipient.actor, + sender.user.username, + ); + await server.services.share.setBlockAllSenders( + recipient.actor, + true, + ); + expect( + await server.services.share.listBlockedSenders( + recipient.actor, + ), + ).toMatchObject({ + all: true, + items: [{ username: sender.user.username }], + }); + + // Turning it back off restores exactly what it hid: `other` gets + // through again, `sender` still does not. + await server.services.share.setBlockAllSenders( + recipient.actor, + false, + ); + await share(other.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + expect(await canRead(recipient.actor, file.path)).toBe(true); + await expect( + share(sender.actor, { + uid: (await makeFile(sender.user)).uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }), + ).rejects.toMatchObject({ + legacyCode: 'recipient_not_accepting_shares', + }); + }); + + it('leaves access already granted alone when switched 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', + }); + await server.services.share.setBlockAllSenders( + recipient.actor, + true, + ); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('drops an invite from a sender blocked before it was claimed', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = blockEmail(); + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + // The address's owner turns up, having blocked the sender in the + // meantime — claiming it now would hand them the one thing they + // said no to. + const claimer = await makeUser(); + await server.stores.user.update(claimer.user.id, { email }); + await server.services.share.blockSender( + claimer.actor, + owner.user.username, + ); + + const claimed = await server.services.share.claimPendingShares( + claimer.user.id, + email, + ); + + expect(claimed).toEqual([]); + expect(await canRead(claimer.actor, file.path)).toBe(false); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + }); + + it('drops an invite when the claimer refuses everyone', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = blockEmail(); + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + // Same reasoning as the per-sender case: an invite can sit for + // weeks, and the address's owner may have said no to all of this + // since it was sent. + const claimer = await makeUser(); + await server.stores.user.update(claimer.user.id, { email }); + await server.services.share.setBlockAllSenders( + claimer.actor, + true, + ); + + expect( + await server.services.share.claimPendingShares( + claimer.user.id, + email, + ), + ).toEqual([]); + expect(await canRead(claimer.actor, file.path)).toBe(false); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + }); + }); + + describe('email variants resolve to the inbox, not the string', () => { + it('shares to the account behind a case or alias variant', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + // `Bob@…` and `bob+x@…` are the recipient's inbox; treating them + // as strangers minted an unclaimable invite instead of a grant. + const variant = `${recipient.email.split('@')[0].toUpperCase()}+tag@test.local`; + const result = await share(owner.actor, { + uid: file.uuid, + recipient: { email: variant }, + mode: 'read', + }); + + expect(result.pending).toBeUndefined(); + expect(result.holder.username).toBe(recipient.user.username); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('a blocked sender cannot reach their blocker through a variant', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + await server.services.share.blockSender( + recipient.actor, + owner.user.username, + ); + + // The variant resolves to the account, so the block applies — + // an exact-string lookup turned this into an invite that emailed + // the blocker on the sender's behalf. + const variant = `${recipient.email.split('@')[0]}+x@test.local`; + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: variant }, + mode: 'read', + }), + ).rejects.toMatchObject({ + statusCode: 403, + legacyCode: 'recipient_not_accepting_shares', + }); + expect( + await server.stores.share.listPendingByEmail(recipient.email), + ).toEqual([]); + }); + + it('claims an invite whatever case the sharer typed it in', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const local = `cased-${Math.random().toString(36).slice(2, 8)}`; + const typed = `${local.toUpperCase()}@Test.Local`; + const confirmed = `${local}@test.local`; + + const invited = await share(owner.actor, { + uid: file.uuid, + recipient: { email: typed }, + mode: 'read', + }); + expect(invited.pending).toBe(true); + // The sharer sees what they typed, not the canonical form. + expect(invited.recipientEmail).toBe(typed); + const [listed] = await server.services.share.listSharesOf( + owner.actor, + { uid: file.uuid }, + ); + expect(listed.recipientEmail).toBe(typed); + + const claimer = await makeUser(); + await server.stores.user.update(claimer.user.id, { + email: confirmed, + clean_email: confirmed, + }); + const claimed = await server.services.share.claimPendingShares( + claimer.user.id, + confirmed, + ); + + expect(claimed).toHaveLength(1); + expect(await canRead(claimer.actor, file.path)).toBe(true); + }); + + it('claims invites when the address arrives by other confirmed routes', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `changed-${Math.random().toString(36).slice(2, 8)}@test.local`; + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + // The change-email flow confirms the new address without ever + // emitting `user.email-confirmed` — the invite has no other + // moment to become a grant. + const claimer = await makeUser(); + await server.stores.user.update(claimer.user.id, { + email, + clean_email: email, + }); + server.clients.event.emit( + 'user.email-changed' as never, + { user_id: claimer.user.id, new_email: email } as never, + {}, + ); + + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (await canRead(claimer.actor, file.path)) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(await canRead(claimer.actor, file.path)).toBe(true); + }); + + + it('claims invites when the address arrives via OIDC signup', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`; + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + // The provider's attestation is the confirmation; there is no + // code-entry step for this event to fire from later. + const fakeReq = { + ip: '127.0.0.1', + headers: {}, + socket: { remoteAddress: '127.0.0.1' }, + }; + const outcome = await runWithContext({ req: fakeReq }, () => + server.services.oidc.createUserFromOIDC('test-provider', { + sub: `sub-${Math.random().toString(36).slice(2, 10)}`, + email, + email_verified: true, + }), + ); + expect(outcome.success, outcome.error).toBe(true); + + const created = outcome.user!; + const actor: Actor = { + user: created as Actor['user'], + effectiveApp: null, + }; + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (await canRead(actor, file.path)) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(await canRead(actor, file.path)).toBe(true); + }); + + it('refuses an address that cannot receive the invite', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const before = await server.stores.share.incrementDailyShareCount( + owner.user.id, + 0, + ); + + // `a@b` must not become a permanent pending row that spent quota. + await expect( + share(owner.actor, { + uid: file.uuid, + recipient: { email: 'a@b' }, + mode: 'read', + }), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'email_not_allowed', + }); + + expect(await server.stores.share.listPendingByEmail('a@b')).toEqual( + [], + ); + expect( + await server.stores.share.incrementDailyShareCount( + owner.user.id, + 0, + ), + ).toBe(before); + }); + }); + + describe('claiming is safe against races and duplicates', () => { + it('claims once when two confirmations race', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `race-${Math.random().toString(36).slice(2, 8)}@test.local`; + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + const claimer = await makeUser(); + await server.stores.user.update(claimer.user.id, { + email, + clean_email: email, + }); + + // The row is claimed before any grant is written, so whichever + // call loses the row has granted nothing it must take back. + const [a, b] = await Promise.all([ + server.services.share.claimPendingShares(claimer.user.id, email), + server.services.share.claimPendingShares(claimer.user.id, email), + ]); + + expect(a.length + b.length).toBe(1); + expect(await canRead(claimer.actor, file.path)).toBe(true); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + }); + + it('clears a duplicate pending row instead of keeping a phantom invite', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `dup-${Math.random().toString(36).slice(2, 8)}@test.local`; + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + // A duplicate that slipped past the in-code dedup (its unique + // index cannot cover NULL holders). Claiming makes it collide; + // it must be cleared, not retried forever. + const { v4: uuidv4 } = await import('uuid'); + await server.clients.db.write( + 'INSERT INTO `share` (`uid`, `issuer_user_id`, `recipient_email`, `fsentry_id`, `mode`, `data`) VALUES (?, ?, ?, ?, ?, ?)', + [uuidv4(), owner.user.id, email, file.id, 'read', '{}'], + ); + + const claimer = await makeUser(); + await server.stores.user.update(claimer.user.id, { + email, + clean_email: email, + }); + const claimed = await server.services.share.claimPendingShares( + claimer.user.id, + email, + ); + + expect(claimed).toHaveLength(1); + expect(await canRead(claimer.actor, file.path)).toBe(true); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + }); + }); }); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index 64c22d022..db4c1bb10 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -21,7 +21,10 @@ 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 { isUniqueViolation } from '../../util/dbError.js'; +import { cleanEmail } from '../../util/email.js'; import type { FSEntry } from '../../stores/fs/FSEntry'; +import type { UserRow } from '../../stores/user/UserStore'; import type { AclMode } from '../acl/ACLService'; import { learnShareRoots, @@ -55,8 +58,10 @@ export interface ResolvedShare { 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. + * The entry's own name, content type and thumbnail. A share has no fsentry + * behind it for the client to stat for them, and the masked path hides + * which folder it sits in. `type` and `thumbnail` come with a listing + * only. */ name?: string; type?: string | null; @@ -80,6 +85,13 @@ export interface ResolvedShare { */ holderId?: number; isNew?: boolean; + /** + * An invite to an address with no confirmed account. No grant exists yet — + * it is written when the recipient confirms the address. + */ + pending?: boolean; + /** Address the invite was aimed at. Set only when `pending`. */ + recipientEmail?: string; } const SHAREABLE_MODES: ReadonlySet = new Set([ @@ -146,11 +158,25 @@ const RETIRE_CHUNK_SIZE = 100; 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. + * The least an address must look like before an invite row is written for it. + * Deliverability is the inbox's business, but `a@b` or a pasted sentence must + * not become a permanent pending share that spent quota. */ -export const SHARE_NOTIFY_WINDOW_SECONDS = 15 * 60; +const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; + +/** + * Where "refuse shares from everyone" lives on the user row. + * + * A key in the existing `metadata` blob rather than a column of its own: the + * share path already holds the recipient's row by the time it asks, so reading + * it costs nothing either way, and a one-bit preference doesn't earn a + * migration per dialect. + */ +const BLOCK_ALL_SHARES_KEY = 'blockAllShares'; + +/** Whether this account refuses shares from everyone. */ +const blocksAllShares = (user: Pick | null): boolean => + Boolean(user?.metadata?.[BLOCK_ALL_SHARES_KEY]); /** * What a share recipient's browser is told about someone else's entry. @@ -255,6 +281,44 @@ export class ShareService extends PuterService { ]).then((): void => undefined); }); + // Owning a confirmed address is what turns it from a claim into an + // identity, and so the only moment an invite may become a grant. That + // happens on more paths than typing a code: an OIDC signup arrives with + // the provider's word for the address, and the change-email flow + // confirms the new one before it lands. Missing any of them strands the + // invite forever — there is no later event to catch. + const claimFor = (user_id?: number, email?: string) => { + if (!user_id || !email) return; + return this.claimPendingShares(user_id, email) + .then((claimed) => + this.services.shareNotification.notifyClaimed( + user_id, + claimed, + ), + ) + .catch((err) => { + console.warn( + '[ShareService] failed to claim pending shares for', + user_id, + err, + ); + }); + }; + this.clients.event.on('user.email-confirmed', (_key, data) => { + const { user_id, email } = (data ?? {}) as { + user_id?: number; + email?: string; + }; + return claimFor(user_id, email); + }); + this.clients.event.on('user.email-changed', (_key, data) => { + const { user_id, new_email } = (data ?? {}) as { + user_id?: number; + new_email?: string; + }; + return claimFor(user_id, new_email); + }); + this.clients.event.on('fs.write.file', (_key, data) => { const entry = (data as { node?: FSEntry })?.node; if (!entry?.uuid) return; @@ -421,7 +485,12 @@ export class ShareService extends PuterService { // 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); + const resolved = await this.#resolveRecipient(input.recipient); + + if (resolved.kind === 'pending') { + return this.#invite(actor, issuerId, entry, resolved.email, mode); + } + const holder = resolved.user; if (holder.id === issuerId) { throw new HttpError(400, 'cannot share with yourself', { @@ -433,6 +502,7 @@ export class ShareService extends PuterService { legacyCode: 'cannot_share_with_owner', }); } + await this.#assertNotBlocked(issuerId, holder); // Changing the mode on an existing share isn't new reach, so it // shouldn't spend budget — only a share to someone who doesn't already @@ -492,57 +562,6 @@ export class ShareService extends PuterService { } } - /** - * 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, @@ -623,11 +642,18 @@ export class ShareService extends PuterService { input: ShareTarget & { recipient: ShareRecipient }, ): Promise<{ revoked: number }> { const issuerId = this.#requireUserId(actor); - const [entry, holder] = await Promise.all([ + const [entry, resolved] = await Promise.all([ this.#resolveEntry(input, actor), this.#resolveRecipient(input.recipient), ]); + // Nothing was granted, so there is only the invitation to take back. + if (resolved.kind === 'pending') { + await this.#assertCanManage(actor, entry); + return this.#cancelInvite(entry, resolved.email, issuerId); + } + const holder = resolved.user; + // Dropping your own access needs no authority over the node — only // enough visibility that the call can't be used to probe for one. const isLeaving = holder.id === issuerId; @@ -969,12 +995,20 @@ export class ShareService extends PuterService { ); 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 pendingRows = await this.stores.share.listPendingOnFsentry( + entry.id, ); + const userIds = [ + ...[...rows, ...inherited.map((i) => i.row)].flatMap( + (row: { issuer_user_id: number; holder_user_id: number }) => [ + Number(row.issuer_user_id), + Number(row.holder_user_id), + ], + ), + ...pendingRows.map((row: { issuer_user_id: number }) => + Number(row.issuer_user_id), + ), + ]; const users = await this.stores.user.getByIds(userIds); const maskedPath = maskEntryPath(entry); @@ -1030,11 +1064,385 @@ export class ShareService extends PuterService { size: entry.size, }), ); - return inheritedShares.concat(own); + // Nobody holds an invite yet, but whoever manages the node needs to + // see who was asked, and be able to take it back. + const pending: ResolvedShare[] = pendingRows.map( + (row: { + uid: string; + mode: string; + issuer_user_id: number; + recipient_email: string; + 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: null }, + pending: true, + // What the sharer typed, when it differs from the canonical + // form the row is keyed on — that is the address they will + // recognize in the dialog. + recipientEmail: + (row.data as { invitedAddress?: string } | null) + ?.invitedAddress ?? row.recipient_email, + createdAt: row.created_at, + issuedByApp: issuedByApp(row), + inheritedFrom: null, + modified: entry.modified, + size: entry.size, + }), + ); + + return inheritedShares.concat(own, pending); + } + + // -- Blocking ----------------------------------------------------- + + /** + * Refuse shares from everyone, or accept them again. The blanket answer to + * the same question `blockSender` answers about one person; the per-sender + * list is kept either way, so turning this off restores it rather than + * asking the user to rebuild it. + * + * `updateMetadata` merges rather than replaces, and refreshes the cached + * row, so the switch bites on the very next share. + */ + async setBlockAllSenders( + actor: Actor, + blocked: boolean, + ): Promise<{ all: boolean }> { + const blockerId = this.#requireUserId(actor); + await this.stores.user.updateMetadata(blockerId, { + [BLOCK_ALL_SHARES_KEY]: blocked, + }); + return { all: blocked }; + } + + /** + * Refuse further shares from `username`. Existing shares stand: access + * someone already has is theirs until it is withdrawn, and a control + * labelled "block" silently revoking it would be a surprise. + */ + async blockSender( + actor: Actor, + username: string, + ): Promise<{ username: string; created: boolean }> { + const blockerId = this.#requireUserId(actor); + const target = await this.#requireUserByUsername(username); + if (target.id === blockerId) { + throw new HttpError(400, 'cannot block yourself', { + legacyCode: 'cannot_block_self', + }); + } + const created = await this.stores.userBlock.create( + blockerId, + target.id, + ); + return { username: target.username as string, created }; + } + + /** Accept shares from `username` again. */ + async unblockSender( + actor: Actor, + username: string, + ): Promise<{ username: string; unblocked: boolean }> { + const blockerId = this.#requireUserId(actor); + const target = await this.#requireUserByUsername(username); + const unblocked = await this.stores.userBlock.deleteByPair( + blockerId, + target.id, + ); + return { username: target.username as string, unblocked }; + } + + /** + * Who the caller refuses shares from, and whether they refuse everyone. + * Usernames only — ids aren't theirs. + */ + async listBlockedSenders(actor: Actor): Promise<{ + all: boolean; + items: Array<{ username: string; createdAt: number }>; + }> { + const blockerId = this.#requireUserId(actor); + const [blocker, rows] = await Promise.all([ + this.stores.user.getById(blockerId), + this.stores.userBlock.listByBlocker(blockerId), + ]); + const users = await this.stores.user.getByIds( + rows.map((row) => Number(row.blocked_user_id)), + ); + const items: Array<{ username: string; createdAt: number }> = []; + for (const row of rows) { + // A miss means the read raced an account deletion. + const username = users.get(Number(row.blocked_user_id))?.username; + if (!username) continue; + items.push({ username, createdAt: Number(row.created_at) }); + } + return { all: blocksAllShares(blocker), items }; + } + + /** + * Stop here when the recipient is not accepting this share. Said plainly + * rather than disguised as a missing recipient, so a sender whose share + * will never arrive stops re-sending it; only a caller who already passed + * the manage check can get this far, so it is no probe for who blocked + * whom. + * + * Refusing everyone and refusing this sender report identically — which of + * the two it is is the recipient's business, not the sender's. + * + * The recipient's row is already in hand from resolution, so the blanket + * switch is free; only a caller who cleared it pays for the pair lookup. + */ + async #assertNotBlocked(issuer: number, holder: UserRow): Promise { + const blocked = + blocksAllShares(holder) || + (await this.stores.userBlock.isBlocked(holder.id, issuer)); + if (!blocked) return; + throw new HttpError(403, 'recipient is not accepting shares', { + legacyCode: 'recipient_not_accepting_shares', + }); } // -- Internals ---------------------------------------------------- + async #requireUserByUsername(username: string): Promise { + const name = typeof username === 'string' ? username.trim() : ''; + const user = name ? await this.stores.user.getByUsername(name) : null; + if (!user?.username) { + throw new HttpError(404, 'Recipient does not exist', { + legacyCode: 'user_does_not_exist', + }); + } + return user; + } + + /** + * Turn every invite aimed at `email` into a real grant, now that its owner + * is known. + * + * Each is re-authorized as it is claimed: an invite can sit for weeks, and + * the issuer may have lost the right to share it since. One that no longer + * holds is dropped, and never blocks the rest. + */ + async claimPendingShares( + holderUserId: number, + email: string, + ): Promise { + // Canonical on both sides: rows are stored cleaned, and the confirmed + // address may be any variant of what the sharer typed. + const pending = await this.stores.share.listPendingByEmail( + cleanEmail(email), + ); + if (pending.length === 0) return []; + + const holder = await this.stores.user.getById(holderUserId); + if (!holder?.username) return []; + + const claimed: ResolvedShare[] = []; + for (const row of pending) { + try { + const entry = await this.stores.fsEntry.getEntryById( + Number(row.fsentry_id), + ); + if (!entry) { + await this.stores.share.deleteByUid(row.uid); + continue; + } + // The address may have been theirs all along. + if ( + entry.userId === holderUserId || + Number(row.issuer_user_id) === holderUserId + ) { + await this.stores.share.deleteByUid(row.uid); + continue; + } + + const issuer = await this.stores.user.getById( + Number(row.issuer_user_id), + ); + if (!issuer) { + await this.stores.share.deleteByUid(row.uid); + continue; + } + // An invite can sit for weeks; its address's owner may have + // stopped accepting shares — from this sender, or from anyone + // — since it was sent. + if ( + blocksAllShares(holder) || + (await this.stores.userBlock.isBlocked( + holderUserId, + Number(row.issuer_user_id), + )) + ) { + await this.stores.share.deleteByUid(row.uid); + continue; + } + const issuerActor = this.#actorFor(issuer); + // Re-authorized against the mode the invite actually grants: + // an issuer can keep authority over `read` while having lost + // `write`, and checking a fixed `read` here would wave a + // write-mode invite through to a grant that then fails. + const stillAllowed = + await this.services.permission.canManagePermission( + issuerActor, + entryPermissionForMode(entry.uuid, row.mode as string), + ); + if (!stillAllowed) { + await this.stores.share.deleteByUid(row.uid); + continue; + } + + // The row is claimed before the grant is written. In this + // order, losing the race to a concurrent cancel means no grant + // exists yet — nothing to clean up. Granting first left a + // durable permission behind whenever the cancel won, and no + // listing showed it, because listings are driven by the rows. + let applied; + try { + applied = await this.stores.share.applyPending({ + uid: row.uid, + holderUserId, + }); + } catch (err) { + // A duplicate of an invite already claimed (or of an + // active share) collides with the unique index the moment + // it gains a holder. It can never be applied, so it is + // noise to be cleared, not an invite to keep retrying. + if (!isUniqueViolation(err)) throw err; + await this.stores.share.deleteByUid(row.uid); + continue; + } + if (!applied) continue; + + try { + await this.services.acl.setUserUser( + issuerActor, + this.#actorFor(holder), + this.#descriptorFor(entry), + row.mode as AclMode, + ); + } catch (err) { + // The row now names a holder but no grant backs it; left + // standing it would re-fail identically on every future + // claim. An invite whose grant cannot be written no longer + // holds, and those are dropped. + await this.stores.share.deleteByUid(row.uid); + throw err; + } + + claimed.push({ + ...this.#resolve(applied, entry, issuerActor, holder), + holderId: holderUserId, + isNew: true, + }); + } catch (err) { + console.warn( + '[ShareService] could not claim pending share', + row.uid, + err, + ); + } + } + return claimed; + } + + /** + * Withdraw an invite before it is claimed. An owner may clear any issuer's + * invite on their node; anyone else only the ones they sent. + */ + async #cancelInvite( + entry: FSEntry, + email: string, + issuerId: number, + ): Promise<{ revoked: number }> { + const isOwner = entry.userId === issuerId; + const rows = ( + await this.stores.share.listPendingByEmail(cleanEmail(email)) + ).filter( + (row: { fsentry_id: number; issuer_user_id: number }) => + Number(row.fsentry_id) === entry.id && + (isOwner || Number(row.issuer_user_id) === issuerId), + ); + let revoked = 0; + for (const row of rows) { + if (await this.stores.share.deleteByUid(row.uid)) revoked += 1; + } + return { revoked }; + } + + /** + * Record a share for an address with no confirmed account. There is nobody + * to grant to, so the row is the whole share until it is claimed. + * + * Spends daily quota: an invite is reach the issuer is handing out, and + * exempting it would make the limit optional. + */ + async #invite( + actor: Actor, + issuerId: number, + entry: FSEntry, + email: string, + mode: AclMode, + ): Promise { + // Checked before anything is written or spent: an address that can't + // receive the invite must not become a permanent pending row. The + // send-time check can't do this — by then the row exists whatever + // happens to the email. + if ( + !EMAIL_SHAPE.test(email) || + !(await this.clients.email.validate(email)) + ) { + throw new HttpError(400, 'invalid recipient email address', { + legacyCode: 'email_not_allowed', + }); + } + + // Stored canonicalized, because claiming matches on it: the confirmed + // address arrives in whatever form the signup normalized to, and an + // exact match against what the sharer happened to type loses the + // invite to a capital letter. The typed form still matters — it is + // where the invite email goes, and what the sharer recognizes in the + // dialog — so it rides along in the row's data. + const canonical = cleanEmail(email); + const existing = await this.stores.share.listPendingByEmail(canonical); + const already = existing.some( + (row: { fsentry_id: number; issuer_user_id: number }) => + Number(row.fsentry_id) === entry.id && + Number(row.issuer_user_id) === issuerId, + ); + const releaseQuota = already + ? null + : await this.#reserveDailyQuota(issuerId); + + try { + const { row, created } = await this.stores.share.upsertPending({ + issuerUserId: issuerId, + recipientEmail: canonical, + displayEmail: email, + fsentryId: entry.id, + mode, + issuerAppUid: actor.app?.uid ?? null, + }); + return { + ...this.#resolve(row, entry, actor, { username: null }), + pending: true, + recipientEmail: email, + isNew: created, + }; + } catch (err) { + await releaseQuota?.(); + throw err; + } + } + #resolve( row: { uid: string; mode: string; created_at?: unknown }, entry: FSEntry, @@ -1045,6 +1453,7 @@ export class ShareService extends PuterService { uid: row.uid, mode: row.mode, path: maskEntryPath(entry), + name: entry.name, entryUid: entry.uuid, isDir: Boolean(entry.isDir), issuer: { username: issuer.user.username ?? null }, @@ -1106,23 +1515,39 @@ export class ShareService extends PuterService { return entry; } - async #resolveRecipient(recipient: ShareRecipient) { + /** + * Who the share is for. An unconfirmed address resolves to an invite rather + * than a failure; a username cannot be invited, there is nothing to reach. + */ + async #resolveRecipient( + recipient: ShareRecipient, + ): Promise< + { kind: 'user'; user: UserRow } | { kind: 'pending'; email: string } + > { const email = recipient?.email?.trim(); const username = recipient?.username?.trim(); + // `findEmailOwner`, not an exact match: `Bob@…` and `bob+x@…` are the + // same inbox, and resolving them to the account is what routes an + // alias through the same self/owner/blocked checks as the address + // itself — an exact match here turned any variant into an invite that + // skipped all three. const user = email - ? await this.stores.user.getByEmail(email) + ? await this.stores.user.findEmailOwner(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) { + if (email && (!user?.username || unconfirmedEmailMatch)) { + return { kind: 'pending', email }; + } + if (!user?.username) { throw new HttpError(404, 'Recipient does not exist', { legacyCode: 'user_does_not_exist', }); } - return user; + return { kind: 'user', user }; } /** diff --git a/src/backend/services/share/shareEmail.test.ts b/src/backend/services/share/shareEmail.test.ts new file mode 100644 index 000000000..9ca1a1c71 --- /dev/null +++ b/src/backend/services/share/shareEmail.test.ts @@ -0,0 +1,666 @@ +/* + * 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 . + */ + +/** + * The mail a share actually sends. + * + * `ShareNotificationService.test.ts` covers who gets told and how often, with + * the notification layer stubbed. This covers what the message says: the spy + * sits on `sendRaw`, so every template renders for real and a variable the + * service doesn't pass shows up as a gap in the text rather than passing + * silently. + * + * An invited address is driven from its own mail — the confirmation code is read + * out of the message that was sent to it — so the invite becoming a real share + * rests on the same path a person would follow. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +const BOOT_TIMEOUT_MS = 120_000; +const SETTLE_MS = 300; + +/** Window for the tests that need several calls to land inside one. */ +const DIGEST_WINDOW_SECONDS = 3; + +/** One send, as the transport would have received it. */ +interface SentEmail { + to: string; + subject: string; + html: string; +} + +const uniqueSuffix = (): string => + `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + +/** An address nobody has an account for. */ +const uninvitedAddress = (): string => `invited-${uniqueSuffix()}@puter.local`; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Run `body` with the digest window widened, for the tests whose point is that + * several calls land in one window. + * + * The suite's default window is short enough that the run doesn't wait on it, + * which is fine for a test making one call — but a test making four sequential + * round trips inside it is racing the timer, and on a loaded runner the last + * one lands after the window closed and arrives as a second email. Widening it + * for those cases costs the run that much wall clock and nothing else: the + * behaviour under test is what the digest *contains*, not how long it waits. + */ +const withDigestWindow = async ( + env: PuterTestEnv, + seconds: number, + body: () => Promise, +): Promise => { + // Read off the service, which holds the same config object the server was + // built with — `#limits()` re-reads it per call, so this takes effect + // without a reboot. Same shape as `OIDCService.test.ts`. + const limits = ( + env.server.services.shareNotification.config as { + share_notify_limits: { emailBatchSeconds?: number }; + } + ).share_notify_limits; + const previous = limits.emailBatchSeconds; + limits.emailBatchSeconds = seconds; + try { + return await body(); + } finally { + limits.emailBatchSeconds = previous; + } +}; + +describe('share email', () => { + let env: PuterTestEnv; + let sent: SentEmail[]; + + beforeAll(async () => { + env = await setupPuterTestEnv({ + // A transport has to exist for the service to try sending at all; + // nothing reaches it, because `sendRaw` is spied below. + email: { + from: '"Puter (test)" ', + host: '127.0.0.1', + port: 1, + }, + // Not set: share email is on by default, which this suite proves. + // Near-immediate digests; the batching itself is tested with two + // senders below, not by waiting a real minute. + share_notify_limits: { emailBatchSeconds: 0.05 }, + } as never); + }, BOOT_TIMEOUT_MS); + + afterAll(async () => { + await env?.shutdown(); + }); + + beforeEach(() => { + sent = []; + vi.spyOn(env.server.clients.email, 'sendRaw').mockImplementation( + async (options: { to: string; subject: string; html?: string }) => { + sent.push({ + to: options.to, + subject: options.subject, + html: options.html ?? '', + }); + return null; + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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 [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return fetch(url, { headers: { authorization: `Bearer ${token}` } }); + }; + + /** A file in the owner's home, written directly — this suite is about the + * mail a share sends, not the upload path. */ + const makeFile = async (owner: { username: string }, label: string) => { + const uid = crypto.randomUUID(); + const name = `${label}-${uid.slice(0, 8)}.txt`; + 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, + `/${owner.username}/${name}`, + user!.id, + Math.floor(Date.now() / 1000), + ], + ); + return { uid, name }; + }; + + const shareWith = ( + sender: { token: string }, + recipient: string, + items: Array<{ uid: string }>, + ) => + post('/share', sender.token, { + recipients: [recipient], + items, + mode: 'read', + }); + + const mailTo = (address: string) => sent.filter((mail) => mail.to === address); + + /** Announcements are off the response path, so they land just after it. */ + const waitForMail = async ( + match: { to: string; subject?: string }, + timeoutMs = 10_000, + ) => { + const deadline = Date.now() + timeoutMs; + for (;;) { + const [mail] = mailTo(match.to).filter( + (candidate) => + !match.subject || candidate.subject.includes(match.subject), + ); + if (mail) return mail; + if (Date.now() >= deadline) { + const held = sent.length + ? sent.map((m) => `[${m.to}] ${m.subject}`).join('\n ') + : '(none)'; + throw new Error( + `no email matching ${JSON.stringify(match)} within ${timeoutMs}ms. Sent:\n ${held}`, + ); + } + await sleep(10); + } + }; + + /** Poll until `check` holds — for state a fire-and-forget event settles. */ + const eventually = async ( + what: string, + check: () => Promise, + timeoutMs = 15_000, + ) => { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await check()) return; + if (Date.now() >= deadline) { + throw new Error(`timed out waiting: ${what}`); + } + await sleep(50); + } + }; + + const sharedWithMe = async (token: string) => { + const res = await get('/share/shared-with-me', token); + expect(res.status).toBe(200); + const body = (await res.json()) as { + items: Array>; + }; + return body.items; + }; + + /** + * A confirmed account for `email`, made the way a real recipient would: sign + * up, then confirm with the code that was mailed to them. + */ + const signUpAndConfirm = async (email: string) => { + const username = `se${uniqueSuffix()}`; + const signup = await fetch(new URL('/signup', env.origin), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username, + email, + password: 'puter-share-email-1234', + }), + }); + expect(signup.status, await signup.clone().text()).toBe(200); + const { token } = (await signup.json()) as { token: string }; + + const codeMail = await waitForMail({ + to: email, + subject: 'confirmation code', + }); + const code = /(\d{6})/.exec(codeMail.subject)?.[1]; + expect(code, `no code in "${codeMail.subject}"`).toBeDefined(); + + const confirmed = await post('/confirm-email', token, { code }); + expect(await confirmed.json()).toMatchObject({ email_confirmed: true }); + + return { username, email, token }; + }; + + it('emails an invite to an address with no account', async () => { + const owner = env.users.user; + const file = await makeFile(owner, 'invite'); + const invitee = uninvitedAddress(); + + const res = await shareWith(owner, invitee, [{ uid: file.uid }]); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ + results: [{ status: 'pending', recipient: invitee }], + }); + + const mail = await waitForMail({ to: invitee }); + expect(mail.subject).toBe( + `${owner.username} shared ${file.name} with you on Puter`, + ); + expect(mail.html).toContain(file.name); + // The address is in the body because it is the one to sign up with: an + // account on any other address will not find the share. + expect(mail.html).toContain(invitee); + expect(mail.html).toContain('Create your account'); + // The full origin, port included — a rebuilt protocol://domain link + // is dead on any self-host that doesn't run on the default port. + expect(mail.html).toContain(`href="${env.origin}"`); + + // Nobody else hears about it — least of all the sender. + await sleep(SETTLE_MS); + expect(sent).toHaveLength(1); + }); + + it('hands an invited address its share once the address is confirmed', async () => { + const owner = env.users.user; + const file = await makeFile(owner, 'claimed'); + const invitee = uninvitedAddress(); + + await shareWith(owner, invitee, [{ uid: file.uid }]); + await waitForMail({ to: invitee }); + + // Whoever manages the item can see who was asked, before any account + // exists to hold it. + const listed = await get('/share/shares', owner.token, { uid: file.uid }); + expect(await listed.json()).toMatchObject({ + items: [{ pending: true, recipient_email: invitee, holder: null }], + }); + + const recipient = await signUpAndConfirm(invitee); + + await eventually('the invite to become a real share', async () => + (await sharedWithMe(recipient.token)).some( + (item) => item.uid_entry === file.uid, + ), + ); + + const [share] = (await sharedWithMe(recipient.token)).filter( + (item) => item.uid_entry === file.uid, + ); + expect(share).toMatchObject({ + mode: 'read', + issuer: owner.username, + holder: recipient.username, + }); + + // The claim is announced in the app, not by email — the invite already + // spent the one message they agreed to receive. + await sleep(SETTLE_MS); + expect( + mailTo(invitee).filter((mail) => mail.html.includes('Open Puter')), + ).toHaveLength(0); + }); + + it('emails an account holder once per window, however often they are shared with', async () => { + const owner = env.users.user; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const first = await makeFile(owner, 'holder-1'); + await shareWith(owner, recipient.email, [{ uid: first.uid }]); + + const mail = await waitForMail({ to: recipient.email }); + expect(mail.subject).toBe( + `${owner.username} shared ${first.name} with you`, + ); + expect(mail.html).toContain(first.name); + expect(mail.html).toContain('Open Puter'); + expect(mail.html).toContain(`href="${env.origin}"`); + expect(mail.html).toContain(recipient.username); + + // A second share to the same pair inside the window is one more thing to + // look at, not one more thing to be interrupted by. + const second = await makeFile(owner, 'holder-2'); + await shareWith(owner, recipient.email, [{ uid: second.uid }]); + await sleep(SETTLE_MS); + expect(mailTo(recipient.email)).toHaveLength(1); + + // Suppressed the announcement, not the share. + await eventually('both items to be listed', async () => { + const uids = (await sharedWithMe(recipient.token)).map( + (item) => item.uid_entry, + ); + return uids.includes(first.uid) && uids.includes(second.uid); + }); + }); + + it('sends one email for a batch of items, counting them', async () => { + // A different sender: the quiet window is per (sender, recipient), and + // the case above has already spent the owner's. + const sender = env.users.admin; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const files = []; + for (const label of ['batch-1', 'batch-2', 'batch-3']) { + files.push(await makeFile(sender, label)); + } + await shareWith( + sender, + recipient.email, + files.map((file) => ({ uid: file.uid })), + ); + + const mail = await waitForMail({ to: recipient.email }); + expect(mail.subject).toBe(`${sender.username} shared 3 items with you`); + expect(mail.html).toContain('shared 3 items'); + + await sleep(SETTLE_MS); + expect(mailTo(recipient.email)).toHaveLength(1); + }); + + + it('holds the window and sends two senders as one digest email', async () => { + const first = env.users.user; + const second = env.users.admin; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const fromFirst = await makeFile(first, 'digest-a'); + const fromSecond = await makeFile(second, 'digest-b'); + await withDigestWindow(env, DIGEST_WINDOW_SECONDS, async () => { + await shareWith(first, recipient.email, [{ uid: fromFirst.uid }]); + await shareWith(second, recipient.email, [{ uid: fromSecond.uid }]); + }); + + // Two people sharing within the window is one email that names them + // both — not two messages ten seconds apart. + const mail = await waitForMail({ to: recipient.email }); + await sleep(SETTLE_MS); + expect(mailTo(recipient.email)).toHaveLength(1); + expect(mail.subject).toBe( + `${first.username} and ${second.username} shared 2 items with you`, + ); + expect(mail.html).toContain(fromFirst.name); + expect(mail.html).toContain(fromSecond.name); + }); + + + + it('counts every file when they are shared one call at a time', async () => { + const owner = env.users.user; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + // Four separate calls, as the dialog makes them: only the first may + // interrupt, but all four must reach the digest. + const files = []; + for (const label of ['one', 'two', 'three', 'four']) { + files.push(await makeFile(owner, label)); + } + await withDigestWindow(env, DIGEST_WINDOW_SECONDS, async () => { + for (const file of files) { + await shareWith(owner, recipient.email, [{ uid: file.uid }]); + } + }); + + const mail = await waitForMail({ to: recipient.email }); + await sleep(SETTLE_MS); + expect(mailTo(recipient.email)).toHaveLength(1); + expect(mail.subject).toBe( + `${owner.username} shared 4 items with you`, + ); + expect(mail.html).toContain('4 items'); + expect(mail.html).toContain(files[0].name); + expect(mail.html).toContain('+1 more'); + }); + + it('names several files shared in one call', async () => { + const sender = env.users.admin; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const files = []; + for (const label of ['multi-a', 'multi-b']) { + files.push(await makeFile(sender, label)); + } + await shareWith( + sender, + recipient.email, + files.map((file) => ({ uid: file.uid })), + ); + + const mail = await waitForMail({ to: recipient.email }); + // Both names, not just whichever happened to be last. + for (const file of files) expect(mail.html).toContain(file.name); + }); + + it('honors an account-wide unsubscribe, and offers the link to those who have not', async () => { + const owner = env.users.user; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const first = await makeFile(owner, 'unsub-1'); + await shareWith(owner, recipient.email, [{ uid: first.uid }]); + const mail = await waitForMail({ to: recipient.email }); + const row = await env.server.stores.user.getByUsername( + recipient.username, + ); + expect(mail.html).toContain(`/unsubscribe?user_uuid=${row!.uuid}`); + + // Only the mail stops: the share and the in-app notification stand. + await env.server.stores.user.update(row!.id, { unsubscribed: 1 }); + await env.server.stores.user.invalidate(row!); + sent = []; + + const second = await makeFile(owner, 'unsub-2'); + await shareWith(owner, recipient.email, [{ uid: second.uid }]); + await sleep(SETTLE_MS); + expect(mailTo(recipient.email)).toHaveLength(0); + await eventually('the share to be listed anyway', async () => + (await sharedWithMe(recipient.token)).some( + (item) => item.uid_entry === second.uid, + ), + ); + }); + + it('sends nothing to a recipient who has blocked the sender', async () => { + const owner = env.users.user; + const recipient = await signUpAndConfirm(uninvitedAddress()); + sent = []; + + const blocked = await post('/share/blocks', recipient.token, { + username: owner.username, + }); + expect(blocked.status).toBe(200); + + const file = await makeFile(owner, 'blocked'); + const res = await shareWith(owner, recipient.email, [{ uid: file.uid }]); + + // Refused per pair, so the envelope carries it, not the status. + expect(await res.json()).toMatchObject({ + status: 'aborted', + results: [ + { status: 'error', code: 'recipient_not_accepting_shares' }, + ], + }); + + await sleep(SETTLE_MS); + expect(sent).toHaveLength(0); + }); +}); + +describe('share email digest durability', () => { + let env: PuterTestEnv; + let sent: SentEmail[]; + + beforeAll(async () => { + env = await setupPuterTestEnv({ + email: { + from: '"Puter (test)" ', + host: '127.0.0.1', + port: 1, + }, + share_email_notifications: true, + // A window no test waits out: mail may only leave via the + // shutdown drain. + share_notify_limits: { emailBatchSeconds: 600 }, + } as never); + }, BOOT_TIMEOUT_MS); + + afterAll(async () => { + await env?.shutdown(); + }); + + beforeEach(() => { + sent = []; + vi.spyOn(env.server.clients.email, 'sendRaw').mockImplementation( + async (options: { to: string; subject: string; html?: string }) => { + sent.push({ + to: options.to, + subject: options.subject, + html: options.html ?? '', + }); + return null; + }, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + + it('sweeps a digest whose timer died with the node that armed it', async () => { + const owner = env.users.user; + const invitee = `orphan-${crypto.randomUUID().slice(0, 8)}@puter.local`; + + // An entry with no timer anywhere — what a restart or a rolling deploy + // leaves behind. Queued far enough in the past to be past its window + // and the sweep's grace on top of it. + await env.server.stores.kv.set({ + key: `share:digest:invite:${invitee}:${crypto.randomUUID()}`, + value: { + kind: 'invite', + to: invitee, + sender: owner.username, + count: 1, + names: ['orphan.txt'], + queuedAt: Date.now() - 60 * 60_000, + }, + }); + + await env.server.services.shareNotification.sweepForTests(); + + expect(sent).toHaveLength(1); + expect(sent[0].to).toBe(invitee); + expect(sent[0].html).toContain('orphan.txt'); + }); + + it('leaves an entry whose window has only just closed to its own timer', async () => { + const owner = env.users.user; + const invitee = `fresh-${crypto.randomUUID().slice(0, 8)}@puter.local`; + + // Past its window, but only just. Claiming an entry is exclusive only + // among flushers that can see each other's deletes, so a sweep that + // pounced the instant a window closed could send a digest another node + // is at that moment sending too. The grace is what keeps that from + // being a coin flip. + await env.server.stores.kv.set({ + key: `share:digest:invite:${invitee}:${crypto.randomUUID()}`, + value: { + kind: 'invite', + to: invitee, + sender: owner.username, + count: 1, + names: ['fresh.txt'], + queuedAt: Date.now() - 11 * 60_000, + }, + }); + + await env.server.services.shareNotification.sweepForTests(); + + expect(sent.filter((mail) => mail.to === invitee)).toHaveLength(0); + }); + + it('holds queued sends durably and drains them once on shutdown', async () => { + const owner = env.users.user; + const invitee = `drain-${crypto.randomUUID().slice(0, 8)}@puter.local`; + const uid = crypto.randomUUID(); + 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, + 'drain.txt', + `/${owner.username}/drain.txt`, + user!.id, + Math.floor(Date.now() / 1000), + ], + ); + const res = await fetch(new URL('/share', env.apiOrigin), { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${owner.token}`, + }, + body: JSON.stringify({ + recipients: [invitee], + items: [{ uid }], + mode: 'read', + }), + }); + expect(res.status).toBe(200); + + // Inside the window nothing has been sent, but the entry is already + // durable — it does not depend on this process's timer surviving. + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(sent).toHaveLength(0); + const { res: listed } = await env.server.stores.kv.list({ + as: 'keys', + pattern: `share:digest:invite:${invitee}:`, + }); + const keys = Array.isArray(listed) + ? listed + : ((listed as { items?: unknown[] })?.items ?? []); + expect(keys).toHaveLength(1); + + // The drain sends it while the transport is still up; a second drain + // finds the entries already claimed and sends nothing again. + await env.server.services.shareNotification.onServerPrepareShutdown(); + await env.server.services.shareNotification.onServerPrepareShutdown(); + + expect(sent).toHaveLength(1); + expect(sent[0].to).toBe(invitee); + expect(sent[0].html).toContain('drain.txt'); + }); +}); diff --git a/src/backend/services/share/shareNotifyTitle.test.ts b/src/backend/services/share/shareNotifyTitle.test.ts new file mode 100644 index 000000000..92857073a --- /dev/null +++ b/src/backend/services/share/shareNotifyTitle.test.ts @@ -0,0 +1,176 @@ +/* + * 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 { + digestLines, + digestSubject, + mergeDigestEntry, + mergeShareSender, + shareNotifyTitle, + shareSendersFromFields, +} from './shareNotifyTitle'; + +describe('shareNotifyTitle', () => { + it('reads the same for one sender as it did before grouping', () => { + expect(shareNotifyTitle([{ username: 'alice', count: 1 }])).toBe( + 'alice shared an item with you', + ); + expect(shareNotifyTitle([{ username: 'alice', count: 3 }])).toBe( + 'alice shared 3 items with you', + ); + }); + + it('names two senders and totals what they shared', () => { + expect( + shareNotifyTitle([ + { username: 'alice', count: 3 }, + { username: 'bob', count: 2 }, + ]), + ).toBe('alice and bob shared 5 items with you'); + }); + + it('counts the rest once there are more names than fit', () => { + const senders = [ + { username: 'alice', count: 1 }, + { username: 'bob', count: 1 }, + { username: 'carol', count: 1 }, + ]; + expect(shareNotifyTitle(senders)).toBe( + 'alice, bob and 1 other shared 3 items with you', + ); + expect( + shareNotifyTitle([...senders, { username: 'dave', count: 6 }]), + ).toBe('alice, bob and 2 others shared 9 items with you'); + }); + + it('says something sensible when a sender has no username', () => { + expect(shareNotifyTitle([{ username: '', count: 1 }])).toBe( + 'Someone shared an item with you', + ); + expect(shareNotifyTitle([])).toBe('Someone shared 0 items with you'); + }); +}); + +describe('mergeShareSender', () => { + it('adds to a sender already in the list', () => { + expect( + mergeShareSender([{ username: 'alice', count: 2 }], 'alice', 3), + ).toEqual([{ username: 'alice', count: 5 }]); + }); + + it('appends a new sender in the order they arrived', () => { + expect( + mergeShareSender([{ username: 'alice', count: 1 }], 'bob', 2), + ).toEqual([ + { username: 'alice', count: 1 }, + { username: 'bob', count: 2 }, + ]); + }); + + it('leaves the list it was given alone', () => { + const senders = [{ username: 'alice', count: 1 }]; + mergeShareSender(senders, 'alice', 4); + expect(senders).toEqual([{ username: 'alice', count: 1 }]); + }); + + it('groups every nameless sender together', () => { + expect(mergeShareSender([], undefined, 1)).toEqual([ + { username: 'Someone', count: 1 }, + ]); + }); +}); + +describe('shareSendersFromFields', () => { + it('reads back the grouped shape', () => { + expect( + shareSendersFromFields({ + senders: [{ username: 'alice', count: 2 }], + }), + ).toEqual([{ username: 'alice', count: 2 }]); + }); + + it('reads the single-sender shape written before grouping existed', () => { + // A notification can outlive the deploy that changes its shape; + // dropping its sender would make the next share read as the first. + expect( + shareSendersFromFields({ username: 'alice', count: 3 }), + ).toEqual([{ username: 'alice', count: 3 }]); + }); + + it('has nothing to say about fields that carry no sender', () => { + expect(shareSendersFromFields(undefined)).toEqual([]); + expect(shareSendersFromFields({})).toEqual([]); + expect(shareSendersFromFields({ count: 0 })).toEqual([]); + }); + + it('drops a malformed sender rather than counting it as zero items', () => { + expect( + shareSendersFromFields({ + senders: [ + { username: 'alice', count: 2 }, + { username: 'bob', count: 'nonsense' }, + ], + }), + ).toEqual([{ username: 'alice', count: 2 }]); + }); +}); + +describe('email digests', () => { + it('names the item for a single share, counts for more', () => { + expect( + digestSubject([{ username: 'alice', count: 1, names: ['a.txt'] }]), + ).toBe('alice shared a.txt with you'); + expect( + digestSubject( + [ + { username: 'alice', count: 1, names: ['a.txt'] }, + { username: 'bob', count: 2, names: ['b.txt'] }, + ], + { suffix: 'on Puter' }, + ), + ).toBe('alice and bob shared 3 items with you on Puter'); + }); + + it('renders one line per sender, naming what it can', () => { + expect( + digestLines([ + { username: 'alice', count: 1, names: ['a.txt'] }, + { username: 'bob', count: 5, names: ['b.txt', 'c.txt'] }, + { username: 'carol', count: 2, names: [] }, + ]), + ).toEqual([ + { sender: 'alice', what: 'a.txt' }, + { sender: 'bob', what: '5 items — b.txt, c.txt, +3 more' }, + { sender: 'carol', what: '2 items' }, + ]); + }); + + it('merges a sender back into their own digest entry', () => { + const merged = mergeDigestEntry( + [{ username: 'alice', count: 1, names: ['a.txt'] }], + 'alice', + 2, + ['b.txt'], + ); + expect(merged).toEqual([ + { username: 'alice', count: 3, names: ['a.txt', 'b.txt'] }, + ]); + }); +}); diff --git a/src/backend/services/share/shareNotifyTitle.ts b/src/backend/services/share/shareNotifyTitle.ts new file mode 100644 index 000000000..166ab8df5 --- /dev/null +++ b/src/backend/services/share/shareNotifyTitle.ts @@ -0,0 +1,194 @@ +/* + * 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 . + */ + +/** + * What a share notification says, and how several senders fold into one. Pure, + * because the wording is a function of who has shared and how much — not of + * whichever request happened to be last. + */ + +/** One sender's contribution to a grouped notification. */ +export interface ShareSender { + username: string; + count: number; +} + +/** How many senders are named before the rest become "others". */ +const NAMED_LIMIT = 2; + +/** Stands in for a sender with no username, rather than saying "undefined". */ +const ANONYMOUS = 'Someone'; + +/** Items across every sender. */ +export const shareNotifyCount = (senders: ShareSender[]): number => + senders.reduce((total, sender) => total + Math.max(0, sender.count), 0); + +/** + * Add `count` items from `username` to what the recipient was already told. + * Insertion order is kept, so the wording grows by a name rather than + * rearranging itself under someone reading it. + */ +export const mergeShareSender = ( + senders: ShareSender[], + username: string | undefined, + count: number, +): ShareSender[] => { + const name = username || ANONYMOUS; + const merged = senders.map((sender) => ({ ...sender })); + const existing = merged.find((sender) => sender.username === name); + if (existing) { + existing.count += count; + return merged; + } + merged.push({ username: name, count }); + return merged; +}; + +/** + * The senders recorded on a notification's fields. Tolerates the single-sender + * shape written before grouping existed — a row outlives the deploy that + * changes it, and dropping its sender would make the next share read as the + * first. + */ +export const shareSendersFromFields = (fields: unknown): ShareSender[] => { + const source = (fields ?? {}) as { + senders?: unknown; + username?: unknown; + count?: unknown; + }; + const named = (value: unknown): string => + typeof value === 'string' && value ? value : ANONYMOUS; + + if (Array.isArray(source.senders)) { + return source.senders + .map((entry) => { + const sender = (entry ?? {}) as { + username?: unknown; + count?: unknown; + }; + return { + username: named(sender.username), + count: Number(sender.count) || 0, + }; + }) + .filter((sender) => sender.count > 0); + } + + const count = Number(source.count) || 0; + if (count <= 0) return []; + return [{ username: named(source.username), count }]; +}; + +/** "alice", "alice and bob", "alice, bob and 2 others". */ +export const senderList = (senders: ShareSender[]): string => { + const names = senders.map((sender) => sender.username || ANONYMOUS); + if (names.length === 0) return ANONYMOUS; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + const rest = names.length - NAMED_LIMIT; + return `${names.slice(0, NAMED_LIMIT).join(', ')} and ${rest} ${ + rest === 1 ? 'other' : 'others' + }`; +}; + +/** Unchanged for one sender, so nothing regresses for the common case. */ +export const shareNotifyTitle = (senders: ShareSender[]): string => { + const count = shareNotifyCount(senders); + const what = count === 1 ? 'an item' : `${count} items`; + return `${senderList(senders)} shared ${what} with you`; +}; + +// -- Email digests ------------------------------------------------------ +// +// Email can't be rewritten the way a notification can, so it gets the grouped +// wording by being held briefly and merged. These shapes are the accumulator. + +/** One sender's contribution to a digest email. */ +export interface DigestEntry { + username: string; + count: number; + /** Item names, newest last. */ + names: string[]; +} + +/** How many item names one digest line spells out before counting the rest. */ +const NAMED_ITEMS_LIMIT = 3; + +/** Fold `count` items (named where known) from `username` into the digest. */ +export const mergeDigestEntry = ( + entries: DigestEntry[], + username: string | undefined, + count: number, + names: string[] = [], +): DigestEntry[] => { + const name = username || 'Someone'; + const merged = entries.map((entry) => ({ + ...entry, + names: [...entry.names], + })); + const existing = merged.find((entry) => entry.username === name); + if (existing) { + existing.count += count; + existing.names.push(...names); + return merged; + } + merged.push({ username: name, count, names: [...names] }); + return merged; +}; + +/** + * The digest's subject: "alice shared report.txt with you" when there is + * exactly one named item, counts otherwise. + */ +export const digestSubject = ( + entries: DigestEntry[], + opts: { suffix?: string } = {}, +): string => { + const total = entries.reduce( + (sum, entry) => sum + Math.max(0, entry.count), + 0, + ); + const what = + total === 1 + ? (entries.find((entry) => entry.names.length > 0)?.names[0] ?? + 'an item') + : `${total} items`; + const base = `${senderList(entries)} shared ${what} with you`; + return opts.suffix ? `${base} ${opts.suffix}` : base; +}; + +/** One rendered line per sender: who, and what they shared. */ +export const digestLines = ( + entries: DigestEntry[], +): Array<{ sender: string; what: string }> => + entries.map((entry) => { + const named = entry.names.slice(0, NAMED_ITEMS_LIMIT); + let what: string; + if (entry.count === 1 && named.length === 1) { + what = named[0]; + } else if (named.length === 0) { + what = `${entry.count} items`; + } else { + const rest = entry.count - named.length; + what = + `${entry.count} items — ${named.join(', ')}` + + (rest > 0 ? `, +${rest} more` : ''); + } + return { sender: entry.username, what }; + }); diff --git a/src/backend/stores/index.ts b/src/backend/stores/index.ts index c6e06b834..7e0c1607d 100644 --- a/src/backend/stores/index.ts +++ b/src/backend/stores/index.ts @@ -31,6 +31,7 @@ import { SessionStore } from './session/SessionStore.js'; import { ShareStore } from './share/ShareStore.js'; import { SubdomainStore } from './subdomain/SubdomainStore.js'; import { SystemKVStore } from './systemKv/SystemKVStore.js'; +import { UserBlockStore } from './userBlock/UserBlockStore.js'; import { UserStore } from './user/UserStore.js'; import type { IPuterStoreRegistry } from './types.js'; @@ -58,6 +59,7 @@ declare module './types.js' { permission: PermissionStore; session: SessionStore; oidc: OIDCStore; + userBlock: UserBlockStore; } } @@ -69,7 +71,7 @@ declare module './types.js' { // stores/services can lean on them for cached lookups. // FSEntryStore depends on `kv` (pending-upload sessions live there). // S3ObjectStore is a leaf (clients.s3 only). -// SessionStore / ShareStore are leaves — only use clients.db. +// SessionStore / ShareStore / UserBlockStore are leaves — only use clients.db. export const puterStores = { kv: SystemKVStore, meteringBuffer: MeteringBufferStore, @@ -86,4 +88,5 @@ export const puterStores = { permission: PermissionStore, session: SessionStore, oidc: OIDCStore, + userBlock: UserBlockStore, } satisfies IPuterStoreRegistry; diff --git a/src/backend/stores/notification/NotificationStore.js b/src/backend/stores/notification/NotificationStore.js index 570910739..8c11bcfc3 100644 --- a/src/backend/stores/notification/NotificationStore.js +++ b/src/backend/stores/notification/NotificationStore.js @@ -116,6 +116,27 @@ export class NotificationStore extends PuterStore { return this.getByUid(uid, { userId }); } + /** + * Rewrite a notification the recipient hasn't dismissed. `shown` is cleared + * with it, so changed wording goes out again on their next connect — + * `#sendUnreads` only carries what was never shown. The unacknowledged + * count doesn't move, so there is nothing to invalidate. + * + * False when no such row exists, so callers can fall back to a fresh + * notification instead of dropping what they were reporting. + * + * @param {string} uid @param {number} userId @param {unknown} value + */ + async updateValue(uid, userId, value) { + const serialized = + typeof value === 'string' ? value : JSON.stringify(value ?? {}); + const result = await this.clients.db.write( + 'UPDATE `notification` SET `value` = ?, `shown` = NULL WHERE `uid` = ? AND `user_id` = ? AND `acknowledged` IS NULL', + [serialized, uid, userId], + ); + return (result?.affectedRows ?? result?.changes ?? 0) > 0; + } + async markAcknowledged(uid, userId) { const now = Math.floor(Date.now() / 1000); const result = await this.clients.db.write( diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 0a9b12873..d93b21f8a 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -186,8 +186,111 @@ export class ShareStore extends PuterStore { return Number(rows[0]?.count ?? 0); } + /** + * Pending invites for one address: a share aimed at someone who had no + * account when it was made. `fsentry_id` distinguishes these from the + * legacy invite rows, which name no node. + * + * @param {string} recipientEmail + */ + async listPendingByEmail(recipientEmail) { + const rows = await this.clients.db.read( + 'SELECT * FROM `share` WHERE `recipient_email` = ? AND ' + + '`holder_user_id` IS NULL AND `fsentry_id` IS NOT NULL ' + + 'ORDER BY `id`', + [recipientEmail], + ); + return rows.map((r) => this.#normalizeRow(r)); + } + + /** + * Unclaimed invites on one node, whoever sent them. What someone managing + * the node needs to see who has been asked but has not arrived. + * + * @param {number} fsentryId + */ + async listPendingOnFsentry(fsentryId) { + const rows = await this.clients.db.read( + 'SELECT * FROM `share` WHERE `fsentry_id` = ? AND ' + + '`holder_user_id` IS NULL ORDER BY `id`', + [fsentryId], + ); + return rows.map((r) => this.#normalizeRow(r)); + } + // -- Writes ------------------------------------------------------- + /** + * Record an invite for an address with no account yet, or move an existing + * one to a new mode. + * + * Deduped on (email, node, issuer) in code: the unique index covers + * `holder_user_id`, which is NULL here, and SQL treats NULLs as distinct — + * so re-inviting would otherwise pile up rows. + * + * `recipientEmail` is the canonical form claims match on; `displayEmail` is + * what the sharer typed, kept for the dialog and nothing else. + * + * @param {object} input + * @param {number} input.issuerUserId + * @param {string} input.recipientEmail + * @param {string} [input.displayEmail] + * @param {number} input.fsentryId + * @param {string} input.mode + * @param {string | null} [input.issuerAppUid] + */ + async upsertPending({ + issuerUserId, + recipientEmail, + displayEmail, + fsentryId, + mode, + issuerAppUid = null, + }) { + if (!issuerUserId || !recipientEmail || !fsentryId || !mode) { + throw new Error( + 'upsertPending: issuerUserId, recipientEmail, fsentryId and mode are required', + ); + } + + const existing = await this.clients.db.read( + 'SELECT `uid` FROM `share` WHERE `recipient_email` = ? AND ' + + '`fsentry_id` = ? AND `issuer_user_id` = ? AND ' + + '`holder_user_id` IS NULL LIMIT 1', + [recipientEmail, fsentryId, issuerUserId], + ); + if (existing[0]?.uid) { + await this.clients.db.write( + 'UPDATE `share` SET `mode` = ? WHERE `uid` = ?', + [mode, existing[0].uid], + ); + return { + row: await this.getByUid(existing[0].uid), + created: false, + }; + } + + const uid = uuidv4(); + await this.clients.db.write( + 'INSERT INTO `share` (`uid`, `issuer_user_id`, `recipient_email`, ' + + '`fsentry_id`, `mode`, `data`) VALUES (?, ?, ?, ?, ?, ?)', + [ + uid, + issuerUserId, + recipientEmail, + fsentryId, + mode, + JSON.stringify({ + ...(issuerAppUid ? { issuerAppUid } : {}), + ...(displayEmail && displayEmail !== recipientEmail + ? { invitedAddress: displayEmail } + : {}), + }), + ], + ); + return { row: await this.getByUid(uid), created: true }; + } + async create({ issuerUserId, recipientEmail, data }) { if (!issuerUserId || !recipientEmail) { throw new Error( diff --git a/src/backend/stores/systemKv/SystemKVStore.test.ts b/src/backend/stores/systemKv/SystemKVStore.test.ts index 3719c7f79..b60a169bb 100644 --- a/src/backend/stores/systemKv/SystemKVStore.test.ts +++ b/src/backend/stores/systemKv/SystemKVStore.test.ts @@ -1117,4 +1117,20 @@ describe('SystemKVStore', () => { ).rejects.toMatchObject({ statusCode: 403 }); }); }); + + describe('take', () => { + it('returns the value to exactly one caller, null after', async () => { + await target.set({ key: 'claim-me', value: { by: 'me' } }, opts); + + const first = await target.take({ key: 'claim-me' }, opts); + expect(first.res).toEqual({ by: 'me' }); + + // The delete IS the claim — a second taker finds nothing, which + // is what lets racing flushers send a queued item exactly once. + const second = await target.take({ key: 'claim-me' }, opts); + expect(second.res).toBeNull(); + const { res } = await target.get({ key: 'claim-me' }, opts); + expect(res).toBeNull(); + }); + }); }); diff --git a/src/backend/stores/systemKv/SystemKVStore.ts b/src/backend/stores/systemKv/SystemKVStore.ts index 360fcaa1e..5326f0f50 100644 --- a/src/backend/stores/systemKv/SystemKVStore.ts +++ b/src/backend/stores/systemKv/SystemKVStore.ts @@ -759,8 +759,7 @@ export class SystemKVStore extends PuterStore { fetched = response.Item ? [response.Item as KvCachedItem] : []; fetchUnits = Number( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 0, + number | undefined) ?? 0, ); } @@ -832,8 +831,7 @@ export class SystemKVStore extends PuterStore { probeUsage, writeUsage( response.ConsumedCapacity?.CapacityUnits as - | number - | undefined, + number | undefined, ), ), }; @@ -927,8 +925,48 @@ export class SystemKVStore extends PuterStore { probeUsage, writeUsage( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 1, + number | undefined) ?? 1, + ), + ), + }; + } + + /** + * Delete a key and return what it held — an atomic claim. However many + * callers race the same key, exactly one gets the value; the rest get + * null. + */ + async take( + { key }: { key: string }, + opts?: KVOpts, + ): Promise> { + assertKey(key); + const actor = ensureActor(opts); + const namespace = getNamespace(actor, opts); + const probeUsage = await this.#assertNotPrivate(namespace, key, opts); + + const response = await this.clients.dynamo.del( + this.tableName, + { namespace, key }, + { returnOld: true }, + ); + await this.#invalidate(namespace, [key]); + + const old = response.Attributes as + { value?: unknown; ttl?: number } | undefined; + const now = Date.now() / 1000; + const res = + old === undefined || (old.ttl && old.ttl <= now) + ? null + : (old.value ?? null); + + return { + res, + usage: addUsage( + probeUsage, + writeUsage( + (response.ConsumedCapacity?.CapacityUnits as + number | undefined) ?? 1, ), ), }; @@ -1006,9 +1044,7 @@ export class SystemKVStore extends PuterStore { | { key: string; value: unknown }[] | { items: - | string[] - | unknown[] - | { key: string; value: unknown }[]; + string[] | unknown[] | { key: string; value: unknown }[]; cursor?: string; total?: number; } @@ -1087,8 +1123,7 @@ export class SystemKVStore extends PuterStore { usage, readUsage( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 1, + number | undefined) ?? 1, ), ); return response; @@ -1105,8 +1140,7 @@ export class SystemKVStore extends PuterStore { const skip = await runQuery(remaining, startKey, 'COUNT'); remaining -= Number(skip.Count ?? 0); startKey = skip.LastEvaluatedKey as - | Record - | undefined; + Record | undefined; if (!startKey) { exhausted = remaining > 0; break; @@ -1129,8 +1163,7 @@ export class SystemKVStore extends PuterStore { >), ); nextKey = response.LastEvaluatedKey as - | Record - | undefined; + Record | undefined; pages++; if (normalizedLimit === undefined) { // Legacy full listing: follow continuation pages so the @@ -1166,8 +1199,7 @@ export class SystemKVStore extends PuterStore { const counted = await runQuery(0, countKey, 'COUNT'); total += Number(counted.Count ?? 0); countKey = counted.LastEvaluatedKey as - | Record - | undefined; + Record | undefined; } while (countKey); } @@ -1522,8 +1554,7 @@ export class SystemKVStore extends PuterStore { probeUsage, writeUsage( (response.ConsumedCapacity?.CapacityUnits as - | number - | undefined) ?? 1, + number | undefined) ?? 1, ), ), }; diff --git a/src/backend/stores/userBlock/UserBlockStore.test.ts b/src/backend/stores/userBlock/UserBlockStore.test.ts new file mode 100644 index 000000000..57c4594e0 --- /dev/null +++ b/src/backend/stores/userBlock/UserBlockStore.test.ts @@ -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 . + */ + +/** + * Against a real database, so the table and its unique index are exercised on + * whichever engine the run is configured for — `PUTER_TEST_DB_ENGINE=postgres` + * covers the second dialect's migration. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PuterServer } from '../../server.js'; +import { createTestUser, setupTestServer } from '../../testUtil.js'; + +describe('UserBlockStore', () => { + let server: PuterServer; + + beforeAll(async () => { + server = await setupTestServer(); + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + const makeUser = async () => { + const username = `ub${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'); + return user; + }; + + it('round-trips a block, idempotently in both directions', async () => { + const blocker = await makeUser(); + const blocked = await makeUser(); + const store = server.stores.userBlock; + + expect(await store.isBlocked(blocker.id, blocked.id)).toBe(false); + expect(await store.create(blocker.id, blocked.id)).toBe(true); + expect(await store.create(blocker.id, blocked.id)).toBe(false); + expect(await store.isBlocked(blocker.id, blocked.id)).toBe(true); + + // Blocking is one-directional: it says nothing about the other way. + expect(await store.isBlocked(blocked.id, blocker.id)).toBe(false); + + expect(await store.deleteByPair(blocker.id, blocked.id)).toBe(true); + expect(await store.deleteByPair(blocker.id, blocked.id)).toBe(false); + expect(await store.isBlocked(blocker.id, blocked.id)).toBe(false); + }); + + it('lists a blocker’s own rows, most recent first', async () => { + const blocker = await makeUser(); + const other = await makeUser(); + const first = await makeUser(); + const second = await makeUser(); + const store = server.stores.userBlock; + + await store.create(blocker.id, first.id); + await store.create(blocker.id, second.id); + await store.create(other.id, first.id); + + const rows = await store.listByBlocker(blocker.id); + expect(rows.map((row) => Number(row.blocked_user_id))).toEqual([ + second.id, + first.id, + ]); + expect(Number.isFinite(Number(rows[0].created_at))).toBe(true); + }); +}); diff --git a/src/backend/stores/userBlock/UserBlockStore.ts b/src/backend/stores/userBlock/UserBlockStore.ts new file mode 100644 index 000000000..5a296990c --- /dev/null +++ b/src/backend/stores/userBlock/UserBlockStore.ts @@ -0,0 +1,100 @@ +/* + * 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 { PuterStore } from '../types'; + +/** One row of `user_block`. `created_at` is unix seconds. */ +export interface UserBlockRow { + id: number; + blocker_user_id: number; + blocked_user_id: number; + created_at: number; +} + +/** + * Persistence for one user refusing contact from another (`user_block`). + * + * Both queries stay on `idx_user_block_pair`, since the check runs on the share + * path. Uncached: a block has to bite immediately, and one indexed lookup per + * share isn't worth trading that for. + */ +export class UserBlockStore extends PuterStore { + // -- Reads -------------------------------------------------------- + + /** Whether `blockerUserId` refuses shares from `blockedUserId`. */ + async isBlocked( + blockerUserId: number, + blockedUserId: number, + ): Promise { + const rows = await this.clients.db.read( + 'SELECT 1 AS hit FROM `user_block` WHERE `blocker_user_id` = ? AND `blocked_user_id` = ? LIMIT 1', + [blockerUserId, blockedUserId], + ); + return rows.length > 0; + } + + /** Everyone `blockerUserId` has blocked, most recent first. */ + async listByBlocker(blockerUserId: number): Promise { + const rows = await this.clients.db.read( + 'SELECT * FROM `user_block` WHERE `blocker_user_id` = ? ORDER BY `id` DESC', + [blockerUserId], + ); + return rows as unknown as UserBlockRow[]; + } + + // -- Writes ------------------------------------------------------- + + /** Idempotent. Returns whether this call is what created the block. */ + async create( + blockerUserId: number, + blockedUserId: number, + ): Promise { + if (await this.isBlocked(blockerUserId, blockedUserId)) return false; + try { + await this.clients.db.write( + 'INSERT INTO `user_block` (`blocker_user_id`, `blocked_user_id`, `created_at`) VALUES (?, ?, ?)', + [blockerUserId, blockedUserId, Math.floor(Date.now() / 1000)], + ); + return true; + } catch (err) { + // Two clicks can race past the check above; the unique index + // decides, and losing that race is the outcome the caller wanted. + if (await this.isBlocked(blockerUserId, blockedUserId)) + return false; + throw err; + } + } + + /** Lift a block. Returns whether there was one to lift. */ + async deleteByPair( + blockerUserId: number, + blockedUserId: number, + ): Promise { + const result = await this.clients.db.write( + 'DELETE FROM `user_block` WHERE `blocker_user_id` = ? AND `blocked_user_id` = ?', + [blockerUserId, blockedUserId], + ); + return ( + ((result as { affectedRows?: number; changes?: number }) + ?.affectedRows ?? + (result as { changes?: number })?.changes ?? + 0) > 0 + ); + } +} diff --git a/src/backend/types.ts b/src/backend/types.ts index bfd270219..975a5e074 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -239,7 +239,12 @@ export interface IPreludeConfig { * an RCS agent provisioned in the Prelude account to actually use RCS. */ preferredChannel?: - 'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram'; + | 'sms' + | 'rcs' + | 'whatsapp' + | 'viber' + | 'zalo' + | 'telegram'; } /** @@ -825,6 +830,14 @@ interface IConfigOptional { /** When true, ACL grants read/list/see on `//Public` to any actor. */ enable_public_folders: boolean; + /** + * Whether a recipient who already has an account is emailed about a share + * as well as notified in-app. **On unless set to false**; recipients + * decline with the unsubscribe link the mail carries, or by blocking a + * sender. An invite to an address with no account is emailed regardless. + */ + share_email_notifications?: 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 @@ -833,6 +846,35 @@ interface IConfigOptional { */ share_daily_limit?: number; + /** + * How often a share may interrupt its recipient — the notification pushed + * to their screen and the email that goes with it. The share itself is + * never refused for being over budget; only the announcement is dropped. + * + * Both axes are needed: the pair bounds hold one sharer back, and the + * recipient bounds are what stop many senders from burying one person + * between them. Omit any field for the built-in default; a non-positive + * value removes that bound. + */ + share_notify_limits?: { + /** + * Quiet period after one sharer reaches a recipient, in seconds. Also + * how long their notification keeps absorbing new shares. + */ + pairWindowSeconds?: number; + /** Interruptions one sharer may cause a recipient per day. */ + pairDaily?: number; + /** Interruptions a recipient may receive per hour, from anyone. */ + recipientHourly?: number; + /** Same, per day. */ + recipientDaily?: number; + /** + * How long emails to one recipient are held and merged into a single + * digest, in seconds. Default 90; non-positive sends immediately. + */ + emailBatchSeconds?: 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. @@ -1066,8 +1108,7 @@ export interface WithLifecycle extends Object { } export interface WithCostsReporting extends WithLifecycle { - getReportedCosts?: () => - // eslint-disable-next-line @typescript-eslint/no-explicit-any + getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any | Promise[]> // eslint-disable-next-line @typescript-eslint/no-explicit-any | Record[]; diff --git a/src/docs/src/FS/getShares.md b/src/docs/src/FS/getShares.md index bceffc008..72e72a64a 100644 --- a/src/docs/src/FS/getShares.md +++ b/src/docs/src/FS/getShares.md @@ -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 diff --git a/src/docs/src/FS/share.md b/src/docs/src/FS/share.md index b7ae7d565..2ba1479b0 100644 --- a/src/docs/src/FS/share.md +++ b/src/docs/src/FS/share.md @@ -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 Share a file with another user diff --git a/src/docs/src/FS/unshare.md b/src/docs/src/FS/unshare.md index 815847367..61e1e4ddf 100644 --- a/src/docs/src/FS/unshare.md +++ b/src/docs/src/FS/unshare.md @@ -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 Stop sharing a file diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index be28a6410..a244861cc 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -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. diff --git a/src/gui/src/UI/Dashboard/TabSecurity.js b/src/gui/src/UI/Dashboard/TabSecurity.js index 9e728abcc..4905139fd 100644 --- a/src/gui/src/UI/Dashboard/TabSecurity.js +++ b/src/gui/src/UI/Dashboard/TabSecurity.js @@ -20,6 +20,7 @@ import UIWindowDisable2FA from '../Settings/UIWindowDisable2FA.js'; import UIWindow2FASetup from '../UIWindow2FASetup.js'; import UIWindowChangePassword from '../UIWindowChangePassword.js'; +import UIWindowBlockedSenders from '../UIWindowBlockedSenders.js'; import UIWindowManageSessions from '../UIWindowManageSessions.js'; const TabSecurity = { @@ -72,6 +73,20 @@ const TabSecurity = { h += ``; h += ''; + // Blocked senders card + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += '
'; + h += `${i18n('blocked_senders')}`; + h += `${i18n('blocked_senders_summary')}`; + h += '
'; + h += '
'; + h += ``; + h += '
'; + // 2FA card (only for non-temp users with confirmed email) if ( !user.is_temp && user.email_confirmed ) { const twoFaStatusClass = user.otp ? 'dashboard-settings-card-success' : 'dashboard-settings-card-warning'; @@ -126,6 +141,18 @@ const TabSecurity = { }); }); + $el_window.find('.dashboard-section-security .manage-blocked-senders').on('click', function (e) { + UIWindowBlockedSenders({ + window_options: { + parent_uuid: $el_window.attr('data-element_uuid'), + backdrop: true, + close_on_backdrop_click: true, + parent_center: true, + stay_on_top: true, + }, + }); + }); + $el_window.find('.dashboard-section-security .toggle-2fa').on('change', async function (e) { const $toggle = $(this); const $label = $toggle.closest('.dashboard-switch'); diff --git a/src/gui/src/UI/UIDesktop.js b/src/gui/src/UI/UIDesktop.js index a476bbb11..0869f4d07 100644 --- a/src/gui/src/UI/UIDesktop.js +++ b/src/gui/src/UI/UIDesktop.js @@ -215,6 +215,16 @@ async function UIDesktop (options) { window.socket.on('notif.message', async ({ uid, notification }) => { let icon = window.icons[notification.icon]; + // A notification can be re-sent under its own uid when what it says has + // grown — several people sharing with you is one notification that + // counts them. Refresh the one on screen rather than stacking a copy. + const $showing = $(`.notification[data-uid="${html_encode(uid)}"]`); + if ( $showing.length ) { + $showing.find('.notification-title').text(notification.title); + $showing.find('.notification-text').text(notification.text ?? ''); + return; + } + UINotification({ title: notification.title, text: notification.text, diff --git a/src/gui/src/UI/UIWindowBlockedSenders.js b/src/gui/src/UI/UIWindowBlockedSenders.js new file mode 100644 index 000000000..d531bf3cc --- /dev/null +++ b/src/gui/src/UI/UIWindowBlockedSenders.js @@ -0,0 +1,210 @@ +/* + * 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'; + +/** + * Who the current user refuses shares from. + * + * Reuses the share dialog's markup and styles — the two are the same kind of + * thing (a short list of people, one action each), and a second visual language + * for it would be a defect even if it worked. + * + * Unblocking asks for no confirmation: nothing is lost by it, and blocking + * again is one click away. + * + * @param {object} [options] + * @param {object} [options.window_options] Merged into the `UIWindow` call, for + * a caller that owns where this appears (the dashboard passes its own uuid so + * the dialog stacks with it). + */ +async function UIWindowBlockedSenders (options) { + options = options ?? {}; + + let h = ''; + h += ''; + + const $existing = $('.window[data-app="blocked-senders"]'); + if ( $existing.length ) { + $existing.focusWindow(); + return; + } + + const el_window = await UIWindow({ + title: i18n('blocked_senders'), + app: 'blocked-senders', + icon: window.icons['shield.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('.blocked-username').get(0)?.focus({ preventScroll: true }); + }, + window_class: 'window-blocked-senders', + window_css: { height: 'initial' }, + body_css: { width: 'initial', padding: '0', 'background-color': 'rgb(245 247 249)' }, + ...options.window_options, + }); + + const $error = $(el_window).find('.form-error-msg'); + const $success = $(el_window).find('.form-success-msg'); + const $list = $(el_window).find('.blocked-list'); + + const show_error = (message) => { + $success.hide(); + $error.html(html_encode(message)).show(); + }; + + const show_success = (message) => { + $error.hide(); + $success.html(message).show(); + }; + + const api = async (method, body) => { + const resp = await fetch(`${window.api_origin}/share/blocks`, { + method, + headers: { + Authorization: `Bearer ${puter.authToken}`, + 'Content-Type': 'application/json', + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + const parsed = await resp.json().catch(() => ({})); + if ( ! resp.ok ) { + throw new Error(parsed?.message ?? i18n('blocked_failed')); + } + return parsed; + }; + + const render = (items) => { + if ( items.length === 0 ) { + $list.html(``); + return; + } + let rows = ''; + for ( const item of items ) { + const username = html_encode(item.username ?? ''); + rows += ''; + } + $list.html(rows); + }; + + const refresh = async () => { + try { + const { all, items } = await api('GET'); + $(el_window).find('.blocked-all').prop('checked', Boolean(all)); + render(items ?? []); + } catch (e) { + show_error(e?.message ?? i18n('blocked_failed')); + } + }; + + // The per-sender list stays live and editable while everyone is refused: + // turning the blanket switch back off restores exactly what it hid, rather + // than asking the user to rebuild it. + $(el_window).on('change', '.blocked-all', async function () { + const on = $(this).is(':checked'); + $(this).prop('disabled', true); + try { + await api(on ? 'POST' : 'DELETE', { all: true }); + show_success(i18n(on ? 'blocked_all_on' : 'blocked_all_off')); + } catch (e) { + $(this).prop('checked', !on); + show_error(e?.message ?? i18n('blocked_failed')); + } finally { + $(this).prop('disabled', false); + } + }); + + $(el_window).on('click', '.blocked-add-btn', async function () { + const username = $(el_window).find('.blocked-username').val()?.trim(); + if ( ! username ) return; + $(this).prop('disabled', true); + try { + await api('POST', { username }); + $(el_window).find('.blocked-username').val(''); + show_success(i18n('blocked_added', { username })); + await refresh(); + } catch (e) { + show_error(e?.message ?? i18n('blocked_failed')); + } finally { + $(this).prop('disabled', false); + } + }); + + $(el_window).on('keypress', '.blocked-username', function (e) { + if ( e.which === 13 ) $(el_window).find('.blocked-add-btn').trigger('click'); + }); + + $(el_window).on('click', '.blocked-unblock', async function () { + const username = $(this).attr('data-username'); + $(this).prop('disabled', true); + try { + await api('DELETE', { username }); + show_success(i18n('blocked_removed', { username })); + await refresh(); + } catch (e) { + show_error(e?.message ?? i18n('blocked_failed')); + $(this).prop('disabled', false); + } + }); + + await refresh(); + return el_window; +} + +export default UIWindowBlockedSenders; diff --git a/src/gui/src/UI/UIWindowShare.js b/src/gui/src/UI/UIWindowShare.js index 2b58f3c3d..e91f3aff1 100644 --- a/src/gui/src/UI/UIWindowShare.js +++ b/src/gui/src/UI/UIWindowShare.js @@ -29,11 +29,14 @@ import { icons } from '../helpers/actionIcons.js'; // to one is shown as-is rather than quietly rounded up to `read`. const MODES = ['read', 'write', 'manage']; +// Already HTML-safe: `i18n()` encodes what it returns, and an unencoded mode +// from the API is encoded here. Encoding a label again turns the `&` in +// "Can edit & share" into a literal `&`. 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; + return html_encode(mode); }; const options_for = (current) => { @@ -41,7 +44,7 @@ const options_for = (current) => { return modes .map( (mode) => - ``, + ``, ) .join(''); }; @@ -147,7 +150,17 @@ async function UIWindowShare (options) { rows += ''; + continue; + } + if ( share.pending ) { + const invited = html_encode(share.recipientEmail ?? ''); + rows += ''; continue; } @@ -177,14 +190,21 @@ async function UIWindowShare (options) { $(this).prop('disabled', true); try { - await puter.fs.share({ + const created = 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) })); + // "Shared with" would claim access an invite does not grant. + // `i18n()` encodes its replacements; encoding first would show the + // entities to anyone whose address or username contains one. + show_success( + created.some((share) => share.pending) + ? i18n('share_invited', { recipient }) + : i18n('share_shared_with', { recipient }), + ); invalidate_shared_roots(); await refresh(); } catch (e) { @@ -200,7 +220,7 @@ async function UIWindowShare (options) { $(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) })); + show_success(i18n('share_shared_with', { recipient: holder })); invalidate_shared_roots(); await refresh(); } catch (e) { @@ -212,18 +232,35 @@ async function UIWindowShare (options) { $(el_window).on('click', '.share-revoke', async function () { const holder = $(this).attr('data-holder'); + const is_pending = $(this).closest('.share-row').hasClass('share-row-pending'); const confirmed = await UIAlert({ - message: i18n('share_confirm_remove', { recipient: holder }), + message: is_pending + ? i18n('share_confirm_cancel_invite', { recipient: holder }) + : i18n('share_confirm_remove', { recipient: holder }), buttons: [ { label: i18n('share_remove'), value: true, type: 'primary' }, { label: i18n('cancel'), value: false }, ], + // Stack the confirmation with the dialog that opened it. In + // fullpage/dashboard mode this window is promoted to the + // stay-on-top band, where an alert defaulting to `stay_on_top: + // false` renders underneath it — leaving a confirmation the user + // can't reach without closing the dialog behind it. + parent_uuid: $(el_window).attr('data-element_uuid'), + stay_on_top: $(el_window).attr('data-stay_on_top') === 'true', }); 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) })); + // `i18n()` encodes what it returns, replacements included, so the + // raw value goes in — encoding first would show the entities to + // anyone whose address or username contains one. + show_success( + is_pending + ? i18n('share_invite_cancelled', { recipient: holder }) + : i18n('share_access_removed', { recipient: holder }), + ); invalidate_shared_roots(); await refresh(); } catch (e) { diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 3a1554faf..8ba76589b 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -6576,6 +6576,27 @@ html.dark-mode .usage-table-show-less:hover { padding: 6px 0; } +/* The blanket "refuse everyone" switch, above the per-sender list. */ +.blocked-all-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #eef1f4; + cursor: pointer; +} + +.blocked-all-row input { + margin-top: 2px; + flex: none; +} + +.blocked-all-row .share-dialog-empty { + display: block; + padding: 2px 0 0; +} + .share-row { display: flex; align-items: center; diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 017787add..7be5497d2 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -390,9 +390,33 @@ const en = { share_done: 'Done', share_failed: 'Could not share this item.', share_shared_with: 'Shared with {{recipient}}', + share_invited: 'Invited {{recipient}} — they’ll get access once they join', + share_awaiting_signup: 'Invited', + share_cancel_invite: 'Cancel invitation', + share_confirm_cancel_invite: + 'Cancel the invitation sent to {{recipient}}?', + share_invite_cancelled: 'Invitation to {{recipient}} cancelled', share_access_removed: 'Removed {{recipient}}', share_confirm_remove: 'Remove {{recipient}}’s access to this item?', share_remove: 'Remove', + block: 'Block', + unblock: 'Unblock', + manage: 'Manage', + blocked_senders: 'Blocked people', + blocked_senders_summary: 'People who can’t share with you', + blocked_senders_note: + 'Blocked people can’t share anything new with you. What they already shared stays until you remove it.', + blocked_all: 'Don’t let anyone share with me', + blocked_all_note: + 'Refuses every new share, whoever it’s from. What’s already shared with you stays.', + blocked_all_on: 'New shares are now refused from everyone', + blocked_all_off: 'You’re accepting shares again', + blocked_add: 'Block someone', + blocked_add_placeholder: 'Username', + blocked_none: 'You haven’t blocked anyone.', + blocked_added: 'Blocked {{username}}', + blocked_removed: 'Unblocked {{username}}', + blocked_failed: 'Could not update your blocked list.', share_you: 'you', share_inherited_via: 'via {{folder}}', share_with: 'Share with:', diff --git a/src/puter-js/src/modules/FileSystem/operations/share.js b/src/puter-js/src/modules/FileSystem/operations/share.js index ea3b42f1a..99566e3f9 100644 --- a/src/puter-js/src/modules/FileSystem/operations/share.js +++ b/src/puter-js/src/modules/FileSystem/operations/share.js @@ -43,7 +43,9 @@ const share = defineOperation({ }, transform: (/** @type {{ status: string, results: Record[] }} */ response) => { const results = response.results ?? []; - const ok = results.filter((r) => r.status === 'success'); + const ok = results.filter( + (r) => r.status === 'success' || r.status === 'pending', + ); if ( ok.length === 0 && results.length > 0 ) { const first = results[0]; throw { diff --git a/src/puter-js/src/modules/FileSystem/operations/shareUtil.js b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js index fad25ad80..436b6bc86 100644 --- a/src/puter-js/src/modules/FileSystem/operations/shareUtil.js +++ b/src/puter-js/src/modules/FileSystem/operations/shareUtil.js @@ -73,6 +73,14 @@ export const toShare = (row) => ({ holder: /** @type {string | null} */ (row.holder ?? null), inheritedFrom: /** @type {string | null} */ (row.inherited_from ?? null), issuedByApp: /** @type {string | null} */ (row.issued_by_app ?? null), + ...(row.status === 'pending' || row.pending === true + ? { + pending: true, + recipientEmail: /** @type {string | null} */ ( + row.recipient_email ?? row.recipientEmail ?? row.recipient ?? null + ), + } + : {}), modified: /** @type {number} */ (row.modified ?? 0), size: /** @type {number | null} */ (row.size ?? null), }); diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js index 46aa8b83f..b6642f831 100644 --- a/src/puter-js/src/modules/FileSystem/types.js +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -294,7 +294,8 @@ * @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} name The item's name. Not set by `getShares()`, + * which describes access to an item the caller already named. * @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 @@ -306,6 +307,11 @@ * @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 {boolean} [pending] True when the recipient's email has no + * confirmed account yet. The share is recorded but grants nothing until they + * create an account with that address and confirm it. + * @property {string | null} [recipientEmail] Address a pending share was sent + * to. Only set when `pending`. * @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. */ diff --git a/src/puter-js/tests/api/suites/sharing.suite.ts b/src/puter-js/tests/api/suites/sharing.suite.ts index 664026b36..72ba615f8 100644 --- a/src/puter-js/tests/api/suites/sharing.suite.ts +++ b/src/puter-js/tests/api/suites/sharing.suite.ts @@ -35,6 +35,8 @@ export default suite('sharing', { t.assert.equal(shares.length, 1); t.assert.equal(shares[0].mode, 'read'); t.assert.equal(shares[0].holder, t.env.users.other.username); + // The path a recipient sees is masked, so the name is what labels it. + t.assert.equal(shares[0].name, path.split('/').pop()); const after = await readAsOther(t, path); t.assert.equal(after.status, 200); @@ -197,4 +199,32 @@ export default suite('sharing', { ); t.assert.equal(result.revoked, 0); }, + + 'sharing with an unregistered address records an invite': async (t) => { + const path = scratch(t, 'invite'); + await t.puter.fs.write(path, 'x'); + const email = `nobody-${Math.random().toString(36).slice(2, 8)}@test.local`; + + // An address with no account is invited rather than refused: the + // share waits for whoever proves they own it. + const created = await t.puter.fs.share(path, email); + t.assert.equal(created.length, 1); + t.assert.equal(created[0].pending, true); + t.assert.equal(created[0].recipientEmail, email); + + // It shows on the item so the sharer can see who was asked. + const shares = await t.puter.fs.getShares(path); + const invite = shares.find((share) => share.pending); + t.assert.ok(invite, 'the invite should be listed'); + t.assert.equal(invite!.recipientEmail, email); + + // And can be taken back before it is claimed. + const result = await t.puter.fs.unshare(path, email); + t.assert.equal(result.revoked, 1); + const after = await t.puter.fs.getShares(path); + t.assert.equal( + after.filter((share) => share.pending).length, + 0, + ); + }, });