mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-06 03:42:15 +00:00
* refactor(share): move share notifications into their own service
* feat(share): invite an address with no account, and email it
* feat(share): surface pending invites in the SDK and share dialog
* fix(share): unreachable revoke confirmation, and double-encoded labels
* feat(share): budget share announcements, group them, and let people block senders
Sharing had one defence against noise: a 15-minute quiet window per (sender,
recipient) pair, which dropped the second share rather than folding it in.
Twenty senders each under their own window could still bury someone, and there
was no way to make one of them stop.
Announcements are now budgeted on two axes through the existing sliding-window
limiter: 1 per 15 minutes and 20 per day from one sender, and 10 per hour /
50 per day to one recipient from anyone. Over budget the share still succeeds
and the recipient's notification is still brought up to date — only the
interruption is dropped. Invite email to an address with no account is budgeted
the same way, keyed on a hash of the canonical address.
Notifications now fold across senders: a new share rewrites the notification
the recipient hasn't dismissed, so "alice and bob shared 5 items with you"
replaces a stack of five. The record is written even when suppressed, so the
count is right whenever they next look.
Blocking is a new `user_block` table with enforcement in ShareService: a blocked
sender's share is refused with `recipient_not_accepting_shares`, spends no
quota, and writes no row, and their unclaimed invite is dropped when the address
is confirmed. Existing access is untouched — that is what revoke is for.
Managed from a Blocked people card in the dashboard's Security tab.
Also publishes the sharing limits, including the ones already on this branch
that were never documented.
* fix(share): name the item in share email, instead of 'an item'
* fix(share): make the invite lifecycle canonical, authorized, and race-safe
* refactor(email): drop EmailClient.isConfigured; callers read config.email
* feat(share): batch share email into a per-recipient digest, durably
* docs(share): document the share error codes; steady the disk migration tests
* fix(share): log why a digest wasn't sent, and recover orphaned ones
* feat(share): email recipients about shares by default, with a way to decline
Share email was off unless a deployment opted in, which meant an account
holder was told about a share in the app only. It is now on unless
`share_email_notifications` is set to false.
The reason it defaulted off was that nobody could decline. So this also
honors `user.unsubscribed` — the account-wide opt-out the /unsubscribe page
already writes and app feedback already respects, which share email ignored —
and the digest carries that link. Sharing and the in-app notification are
unaffected by it; only the mail stops.
The link is composed in the template around an interpolated uuid rather than
passed pre-built: Handlebars escapes interpolated values, so a whole URL came
out as `user_uuid=…`, which browsers decode but link scanners and older
mail clients need not.
* fix(share): count every shared file in the digest, not just the first
* feat(share): let a recipient refuse shares from everyone
Blocking answered "not from this person" but had no answer to "not from
anyone", so the only way to stop a stream of unwanted shares was to name
each sender after they had already reached you.
Stored as a key in the user row's existing `metadata` blob rather than a
column: the share path already holds the recipient's row by the time it
asks, so reading it costs nothing, and a one-bit preference doesn't earn
a migration per dialect. `updateMetadata` merges and refreshes the cached
row, so the switch bites on the very next share.
Refusing everyone reports the same code as refusing one person — which of
the two it is is the recipient's business, not the sender's. Enforced at
both moments the per-sender block is: when the share is issued, and when
a pending invite is claimed. The per-sender list is untouched while the
blanket switch is on, so turning it off restores what it hid.
`GET /share/blocks` now carries `all`; `POST`/`DELETE` take `{ all: true }`
beside the existing `{ username }`. Managed from the same Blocked people
card in the dashboard's Security tab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): keep the digest sweep off a window that still has a timer
The sweep treated an entry as orphaned the moment its window closed, which
is also the moment the node that armed it fires. Claiming an entry is only
exclusive among flushers that can see each other's deletes, so the two
could each claim a share of the same digest and both send. It now waits
out a grace period first, which costs a genuinely stranded digest that
much delay and nothing else.
Both digest listings were capped at 200 with no word when they hit it — a
truncated flush sends a digest that undercounts and reads as complete.
The cap is named and logged.
Also: `#emailHolder` still described share email as off by default, which
it stopped being; the config doc said the batch window defaults to 60s
when it is 90; and the two tests that need several calls inside one window
were racing a 50ms window across four sequential round trips, so they
failed under full-suite load rather than on the behaviour they cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): stop double-encoding the recipient in two dialog messages
`i18n()` encodes what it returns, replacements included, so encoding the
recipient first showed the entities to anyone whose address or username
contains one. Same pattern already fixed two lines above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(notification): widen the mysql shown/acknowledged columns
Both arrived from the v1 schema as `tinyint(1)`, where they were flags.
The backend rework changed the writes to a unix second; sqlite (`INTEGER`)
and postgres (`bigint`) took it and mysql did not, so on mysql every
`markShown` and `markAcknowledged` has failed with
ER_WARN_DATA_OUT_OF_RANGE and left the column NULL. Dismissing a
notification never stuck — the unacknowledged count never moved and one
already delivered came back on every reconnect.
No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a
legacy `1` keeps meaning "yes" once widened. Guarded on the current type,
because changing a column type copies the table and this directory
replays on every boot.
Not reachable from the test suite — it runs against sqlite and postgres,
both of which already have the right type. Verified by hand against mysql:
`/notif/mark-read` and `/notif/mark-ack` now persist, and a dismissed
share notification is no longer the one a later share folds into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
601 lines
22 KiB
TypeScript
601 lines
22 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import type { Request, RequestHandler, Response } from 'express';
|
|
import {
|
|
afterAll,
|
|
afterEach,
|
|
beforeAll,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
} from 'vitest';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import type { Actor } from '../../core/actor.js';
|
|
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
|
import { PuterServer } from '../../server.js';
|
|
import { AppFeedbackService } from '../../services/feedback/AppFeedbackService.js';
|
|
import { setupTestServer } from '../../testUtil.js';
|
|
|
|
// Boots one real PuterServer (in-memory sqlite + mocked externals) and
|
|
// registers AppFeedbackController's decorated routes onto a fresh
|
|
// PuterRouter. Tests drive the captured handlers with stub req/res; the
|
|
// stores/services underneath are the live wired ones, so rows land in the
|
|
// real `app_feedback` table.
|
|
|
|
let server: PuterServer;
|
|
let router: PuterRouter;
|
|
|
|
beforeAll(async () => {
|
|
server = await setupTestServer();
|
|
router = new PuterRouter();
|
|
server.controllers.appFeedback.registerRoutes(router);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.shutdown();
|
|
});
|
|
|
|
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 }> => {
|
|
const username = `fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
|
const created = await server.stores.user.create({
|
|
username,
|
|
uuid: uuidv4(),
|
|
password: null,
|
|
email: `${username}@test.local`,
|
|
free_storage: 100 * 1024 * 1024,
|
|
requires_email_confirmation: false,
|
|
});
|
|
const refreshed = (await server.stores.user.getById(created.id))!;
|
|
return {
|
|
userId: refreshed.id,
|
|
actor: {
|
|
user: {
|
|
id: refreshed.id,
|
|
uuid: refreshed.uuid,
|
|
username: refreshed.username,
|
|
email: refreshed.email ?? null,
|
|
email_confirmed: true,
|
|
} as Actor['user'],
|
|
},
|
|
};
|
|
};
|
|
|
|
const makeApp = async (
|
|
ownerUserId: number,
|
|
opts: { feedbackEnabled?: boolean; indexUrl?: string; name?: string } = {},
|
|
) => {
|
|
const name =
|
|
opts.name ?? `fdbk-app-${Math.random().toString(36).slice(2, 10)}`;
|
|
return await server.stores.app.create(
|
|
{
|
|
name,
|
|
title: `Feedback Test ${name}`,
|
|
index_url: opts.indexUrl ?? `https://${name}.example.com`,
|
|
...(opts.feedbackEnabled ? { feedback_enabled: 1 } : {}),
|
|
},
|
|
{ ownerUserId },
|
|
);
|
|
};
|
|
|
|
// 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. 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<string, unknown> })
|
|
.config;
|
|
const mockEmailConfigured = () => {
|
|
liveConfig().email = { jsonTransport: true };
|
|
};
|
|
|
|
const confirmOwnerEmail = async (userId: number) => {
|
|
await server.clients.db.write(
|
|
'UPDATE `user` SET `email_confirmed` = ? WHERE `id` = ?',
|
|
[server.clients.db.booleanValue(true), userId],
|
|
);
|
|
const user = await server.stores.user.getById(userId);
|
|
if (user) await server.stores.user.invalidate(user);
|
|
};
|
|
|
|
const makeReq = (init: {
|
|
body?: unknown;
|
|
actor?: Actor;
|
|
query?: Record<string, unknown>;
|
|
}): Request => {
|
|
return {
|
|
body: init.body ?? {},
|
|
query: init.query ?? {},
|
|
headers: {},
|
|
actor: init.actor,
|
|
} as unknown as Request;
|
|
};
|
|
|
|
const makeRes = () => {
|
|
const captured: { statusCode: number; body: unknown } = {
|
|
statusCode: 200,
|
|
body: undefined,
|
|
};
|
|
const res = {
|
|
json: vi.fn((value: unknown) => {
|
|
captured.body = value;
|
|
return res;
|
|
}),
|
|
status: vi.fn((code: number) => {
|
|
captured.statusCode = code;
|
|
return res;
|
|
}),
|
|
};
|
|
return { res: res as unknown as Response, captured };
|
|
};
|
|
|
|
const findRoute = (method: string, path: string) => {
|
|
const route = router.routes.find(
|
|
(r) => r.method === method && r.path === path,
|
|
);
|
|
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
|
|
return route;
|
|
};
|
|
|
|
const callRoute = async (
|
|
method: string,
|
|
path: string,
|
|
req: Request,
|
|
res: Response,
|
|
) => {
|
|
const handler: RequestHandler = findRoute(method, path).handler;
|
|
await handler(req, res, () => {
|
|
throw new Error('handler called next() unexpectedly');
|
|
});
|
|
};
|
|
|
|
const submit = (actor: Actor, body: unknown) => {
|
|
const { res, captured } = makeRes();
|
|
return callRoute('post', '/', makeReq({ body, actor }), res).then(
|
|
() => captured,
|
|
);
|
|
};
|
|
|
|
// ── Route gates ─────────────────────────────────────────────────────
|
|
|
|
describe('AppFeedbackController route options', () => {
|
|
it('rejects app actors and cross-origin pages on submit', () => {
|
|
const { options } = findRoute('post', '/');
|
|
// requireUserActor is what makes feedback impossible to submit
|
|
// programmatically with an app token; guiOriginOnly keeps
|
|
// cross-origin browser pages out even with a leaked user token.
|
|
expect(options.requireUserActor).toBe(true);
|
|
expect(options.guiOriginOnly).toBe(true);
|
|
});
|
|
|
|
it('stacks a per-user budget with a per-IP backstop', () => {
|
|
const { options } = findRoute('post', '/');
|
|
const limits = options.rateLimit;
|
|
expect(Array.isArray(limits)).toBe(true);
|
|
const keys = (limits as Array<{ key?: unknown }>).map((l) => l.key);
|
|
expect(keys).toContain('user');
|
|
expect(keys).toContain('ip');
|
|
});
|
|
|
|
it('requires a user actor on the target pre-flight too', () => {
|
|
const { options } = findRoute('get', '/target');
|
|
expect(options.requireUserActor).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── GET /app-feedback/target ────────────────────────────────────────
|
|
|
|
describe('AppFeedbackController GET /target', () => {
|
|
it('throws 400 when neither or both of app/origin are given', async () => {
|
|
const { actor } = await makeUser();
|
|
for (const query of [
|
|
{},
|
|
{ app: 'x', origin: 'https://x.example.com' },
|
|
]) {
|
|
const { res } = makeRes();
|
|
await expect(
|
|
callRoute('get', '/target', makeReq({ query, actor }), res),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
}
|
|
});
|
|
|
|
it('reports enabled:false for an unknown app', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await callRoute(
|
|
'get',
|
|
'/target',
|
|
makeReq({ query: { app: 'no-such-app-xyz' }, actor }),
|
|
res,
|
|
);
|
|
expect(captured.body).toEqual({ enabled: false, app: null });
|
|
});
|
|
|
|
it('reports enabled:false for an app that has not opted in', async () => {
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId);
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await callRoute(
|
|
'get',
|
|
'/target',
|
|
makeReq({ query: { app: app.name }, actor }),
|
|
res,
|
|
);
|
|
expect(captured.body).toMatchObject({ enabled: false });
|
|
});
|
|
|
|
it('reports enabled:true with canonical title/name for an opted-in app', async () => {
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await callRoute(
|
|
'get',
|
|
'/target',
|
|
makeReq({ query: { app: app.uid }, actor }),
|
|
res,
|
|
);
|
|
expect(captured.body).toEqual({
|
|
enabled: true,
|
|
app: { name: app.name, title: app.title },
|
|
});
|
|
});
|
|
|
|
it('resolves an opted-in app whose name starts with "app-"', async () => {
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const name = `app-fdbk-${Math.random().toString(36).slice(2, 10)}`;
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true, name });
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await callRoute(
|
|
'get',
|
|
'/target',
|
|
makeReq({ query: { app: name }, actor }),
|
|
res,
|
|
);
|
|
expect(captured.body).toEqual({
|
|
enabled: true,
|
|
app: { name: app.name, title: app.title },
|
|
});
|
|
});
|
|
|
|
it('resolves an origin to the app whose index_url it matches', async () => {
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const origin = new URL(app.index_url).origin;
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await callRoute(
|
|
'get',
|
|
'/target',
|
|
makeReq({ query: { origin }, actor }),
|
|
res,
|
|
);
|
|
expect(captured.body).toEqual({
|
|
enabled: true,
|
|
app: { name: app.name, title: app.title },
|
|
});
|
|
});
|
|
|
|
it('reports enabled:false when the email transport is unconfigured', async () => {
|
|
// No mockEmailConfigured(): this is the self-hosted no-SMTP default.
|
|
// Feedback that can never be delivered must not be solicited.
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await callRoute(
|
|
'get',
|
|
'/target',
|
|
makeReq({ query: { app: app.name }, actor }),
|
|
res,
|
|
);
|
|
expect(captured.body).toMatchObject({ enabled: false });
|
|
});
|
|
|
|
it('reports enabled:false for an origin with no registered app', async () => {
|
|
const { actor } = await makeUser();
|
|
const { res, captured } = makeRes();
|
|
await callRoute(
|
|
'get',
|
|
'/target',
|
|
makeReq({
|
|
query: { origin: 'https://nobody-registered.example.com' },
|
|
actor,
|
|
}),
|
|
res,
|
|
);
|
|
expect(captured.body).toEqual({ enabled: false, app: null });
|
|
});
|
|
});
|
|
|
|
// ── POST /app-feedback ──────────────────────────────────────────────
|
|
|
|
describe('AppFeedbackController POST /', () => {
|
|
it('throws 400 when message is missing or not a string', async () => {
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const { actor } = await makeUser();
|
|
for (const message of [undefined, 12345, '']) {
|
|
await expect(
|
|
submit(actor, { app: app.name, message }),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
}
|
|
});
|
|
|
|
it('throws 400 when both app and origin are given', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
submit(actor, {
|
|
app: 'x',
|
|
origin: 'https://x.example.com',
|
|
message: 'hi',
|
|
}),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('throws 400 when the origin exceeds the stored column size', async () => {
|
|
// source_origin is VARCHAR(2048) on MySQL/Postgres; a longer origin
|
|
// must be rejected up front, not fail (or silently truncate) at the
|
|
// INSERT after passing every other validation.
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const origin = `${new URL(app.index_url).origin}/${'x'.repeat(2500)}`;
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
submit(actor, { origin, message: 'hi' }),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('throws 403 feedback_not_enabled when the app has not opted in', async () => {
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId);
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
submit(actor, { app: app.name, message: 'hi there' }),
|
|
).rejects.toMatchObject({
|
|
statusCode: 403,
|
|
legacyCode: 'feedback_not_enabled',
|
|
});
|
|
});
|
|
|
|
it('throws 403 for an unknown app and an unknown origin alike', async () => {
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
submit(actor, { app: 'no-such-app-xyz', message: 'hi' }),
|
|
).rejects.toMatchObject({ statusCode: 403 });
|
|
await expect(
|
|
submit(actor, {
|
|
origin: 'https://nobody-registered.example.com',
|
|
message: 'hi',
|
|
}),
|
|
).rejects.toMatchObject({ statusCode: 403 });
|
|
});
|
|
|
|
it('throws 403 feedback_not_enabled when the email transport is unconfigured', async () => {
|
|
// No mockEmailConfigured(): the opted-in app must still refuse — a
|
|
// stored row nothing can read, sold to the sender as delivered, is
|
|
// worse than an honest refusal.
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
submit(actor, { app: app.name, message: 'into the void' }),
|
|
).rejects.toMatchObject({
|
|
statusCode: 403,
|
|
legacyCode: 'feedback_not_enabled',
|
|
});
|
|
});
|
|
|
|
it('throws 400 when the message exceeds the length limit', async () => {
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const { actor } = await makeUser();
|
|
await expect(
|
|
submit(actor, {
|
|
app: app.name,
|
|
message: 'x'.repeat(
|
|
AppFeedbackService.MESSAGE_MAX_LENGTH + 1,
|
|
),
|
|
}),
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('stores a normalized row and responds with an empty object', async () => {
|
|
mockEmailConfigured();
|
|
const { userId: ownerId } = await makeUser();
|
|
const app = await makeApp(ownerId, { feedbackEnabled: true });
|
|
const { actor, userId } = await makeUser();
|
|
const captured = await submit(actor, {
|
|
app: app.uid,
|
|
message: ' Great\r\napp! |