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, + ); + }, });