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: `
+
+ `,
+ },
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 += '
';
+ h += '';
+ h += '';
+
+ h += ``;
+
+ h += ``;
+ h += '