diff --git a/src/backend/drivers/notification/NotificationDriver.appMessage.test.ts b/src/backend/drivers/notification/NotificationDriver.appMessage.test.ts new file mode 100644 index 000000000..52c513913 --- /dev/null +++ b/src/backend/drivers/notification/NotificationDriver.appMessage.test.ts @@ -0,0 +1,426 @@ +/* + * 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, beforeAll, describe, expect, it } from 'vitest'; +import { v4 as uuidv4 } from 'uuid'; +import { makeActor, type Actor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; +import { PuterServer } from '../../server.js'; +import { findNotificationType } from '../../services/notification/notificationTypes.js'; +import { setupTestServer } from '../../testUtil.js'; +import type { IConfig } from '../../types.js'; +import type { NotificationDriver } from './NotificationDriver.js'; + +const makeUser = async (server: PuterServer): Promise => { + const username = `am-${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 user = (await server.stores.user.getById(created.id))!; + return makeActor({ + user: { + id: user.id, + uuid: user.uuid, + username: user.username, + email: user.email ?? null, + email_confirmed: true, + } as Actor['user'], + }); +}; + +const makeApp = async ( + server: PuterServer, + ownerUserId: number, +): Promise<{ id: number; uid: string }> => { + const name = `am-app-${Math.random().toString(36).slice(2, 10)}`; + return (await server.stores.app.create( + { + name, + title: 'App message test', + index_url: `https://${name}.test/`, + }, + { ownerUserId }, + )) as { id: number; uid: string }; +}; + +const asApp = (holder: Actor, app: { id: number; uid: string }): Actor => + makeActor({ user: holder.user, app }); + +/** A worker acts as its user, with its app as the app. */ +const asWorker = (holder: Actor, app: { id: number; uid: string }): Actor => + makeActor({ + user: holder.user, + app, + session: { uid: `ws-${uuidv4()}`, kind: 'worker' }, + }); + +const asIssuedToken = (holder: Actor, issuer: Actor): Actor => + makeActor({ + user: holder.user, + accessToken: { uid: `tok-${uuidv4()}`, issuer, authorized: null }, + }); + +const withActor = (actor: Actor, fn: () => Promise): Promise => + runWithContext({ actor }, fn); + +const message = (fields: Record = {}) => ({ + object: { value: { type: 'app.message', title: 'Hello', ...fields } }, +}); + +/** Every payload the bus carried under `key` while `fn` ran. */ +const captureBus = async ( + server: PuterServer, + key: string, + fn: () => Promise, +): Promise>> => { + const seen: Array> = []; + const handler = (_k: string, data: unknown) => + seen.push(data as Record); + server.clients.event.on(key, handler); + try { + await fn(); + // Listeners may run after `emit` returns. + await new Promise((resolve) => setTimeout(resolve, 20)); + } finally { + server.clients.event.off?.(key, handler); + } + return seen; +}; + +const bootServer = (config: Partial) => { + const state = { + server: null as unknown as PuterServer, + driver: null as unknown as NotificationDriver, + }; + beforeAll(async () => { + state.server = await setupTestServer(config as IConfig); + state.driver = state.server.drivers + .notifications as unknown as NotificationDriver; + }); + afterAll(async () => { + await state.server?.shutdown(); + }); + return state; +}; + +describe('app.message with live delivery over the socket wire', () => { + const state = bootServer({ + notificationAppMessageLimits: { perMinute: 3 }, + }); + + it('stores the message for the acting user, under the app', async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + + let created: Record | null = null; + const pushed = await captureBus( + state.server, + 'outer.gui.notif.message', + async () => { + created = (await withActor(asApp(holder, app), () => + state.driver.create( + message({ text: 'Body', channel: 'replies' }), + ), + )) as Record; + }, + ); + + const uid = created!.uid as string; + expect(created!.value).toEqual({ + type: 'app.message', + title: 'Hello', + text: 'Body', + channel: 'replies', + priority: 'normal', + ttl: 3600, + }); + const row = await state.server.stores.notification.getByUid(uid, { + userId: holder.user.id, + }); + expect(row).toMatchObject({ + user_id: holder.user.id, + app_uid: app.uid, + audience: 'app-user', + type: 'app.message', + }); + // Not silent: it went out on the wire the desktop listens to. + expect(pushed).toEqual([ + { + user_id_list: [holder.user.id], + response: { uid, notification: created!.value }, + }, + ]); + }); + + it("lets the app's worker and the tokens it issued notify their user", async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + + for (const actor of [ + asWorker(holder, app), + asIssuedToken(holder, asApp(holder, app)), + ]) { + const created = (await withActor(actor, () => + state.driver.create(message()), + )) as Record; + const row = await state.server.stores.notification.getByUid( + created.uid as string, + { userId: holder.user.id }, + ); + expect(row).toMatchObject({ + user_id: holder.user.id, + app_uid: app.uid, + }); + } + }); + + it('refuses an app any other type', async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + for (const type of ['share.received', 'app.events.suspended']) { + await expect( + withActor(asApp(holder, app), () => + state.driver.create({ object: { value: { type } } }), + ), + ).rejects.toMatchObject({ + statusCode: 403, + code: 'notification_type_not_allowed', + }); + } + }); + + it('refuses a recipient or app named by the caller', async () => { + const holder = await makeUser(state.server); + const other = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + const otherApp = await makeApp(state.server, holder.user.id!); + + for (const extra of [ + { userId: other.user.id }, + { recipient: other.user.uuid }, + { appUid: otherApp.uid }, + ]) { + await expect( + withActor(asApp(holder, app), () => + state.driver.create({ + object: { ...message().object, ...extra }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + } + const theirs = await state.server.stores.notification.listByUserId( + other.user.id!, + {}, + ); + expect(theirs).toEqual([]); + }); + + it('refuses an app message that fails validation, writing nothing', async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + await expect( + withActor(asApp(holder, app), () => + state.driver.create(message({ icon: 'http://x.test/a.png' })), + ), + ).rejects.toMatchObject({ statusCode: 400, fields: { field: 'icon' } }); + await expect( + withActor(asApp(holder, app), () => + state.driver.create({ + object: { value: { type: 'app.message' } }, + }), + ), + ).rejects.toMatchObject({ + statusCode: 400, + fields: { field: 'title' }, + }); + expect( + await state.server.stores.notification.listByUserId( + holder.user.id!, + {}, + ), + ).toEqual([]); + }); + + it('keeps the holder creating other types silently', async () => { + const holder = await makeUser(state.server); + const pushed = await captureBus( + state.server, + 'outer.gui.notif.message', + () => + withActor(holder, () => + state.driver.create({ + object: { + value: { type: 'share.received', title: 'hi' }, + }, + }), + ), + ); + expect(pushed).toEqual([]); + const rows = await state.server.stores.notification.listByUserId( + holder.user.id!, + {}, + ); + expect(rows.map((r: Record) => r.type)).toEqual([ + 'share.received', + ]); + }); + + it('refuses an app message from the holder, with or without an app', async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + for (const object of [ + message().object, + { ...message().object, appUid: app.uid }, + ]) { + await expect( + withActor(holder, () => state.driver.create({ object })), + ).rejects.toMatchObject({ + statusCode: 403, + code: 'notification_type_not_allowed', + }); + } + }); + + it('limits each app per user per minute', async () => { + const holder = await makeUser(state.server); + const otherUser = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + const otherApp = await makeApp(state.server, holder.user.id!); + + for (let i = 0; i < 3; i++) { + await withActor(asApp(holder, app), () => + state.driver.create(message()), + ); + } + // The app's worker spends the same budget as the app. + await expect( + withActor(asWorker(holder, app), () => + state.driver.create(message()), + ), + ).rejects.toMatchObject({ + statusCode: 429, + legacyCode: 'too_many_requests', + }); + + await expect( + withActor(asApp(holder, otherApp), () => + state.driver.create(message()), + ), + ).resolves.toMatchObject({ uid: expect.any(String) }); + await expect( + withActor(asApp(otherUser, app), () => + state.driver.create(message()), + ), + ).resolves.toMatchObject({ uid: expect.any(String) }); + }); + + it("reads back the app's own messages and no other app's", async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + const otherApp = await makeApp(state.server, holder.user.id!); + const created = (await withActor(asApp(holder, app), () => + state.driver.create(message()), + )) as Record; + await withActor(holder, () => + state.driver.create({ + object: { value: { type: 'share.received', title: 'acct' } }, + }), + ); + + const read = (await withActor(asApp(holder, app), () => + state.driver.read({ uid: created.uid }), + )) as Record; + expect(read.uid).toBe(created.uid); + + for (const subject of [undefined, 'notif:app-user']) { + const listed = (await withActor(asApp(holder, app), () => + state.driver.select({ subject }), + )) as Array>; + expect(listed.map((n) => n.uid)).toEqual([created.uid]); + } + + await expect( + withActor(asApp(holder, otherApp), () => + state.driver.read({ uid: created.uid }), + ), + ).rejects.toMatchObject({ statusCode: 404 }); + expect( + await withActor(asApp(holder, otherApp), () => + state.driver.select({}), + ), + ).toEqual([]); + }); +}); + +describe('app.message with the events fold-in on', () => { + const state = bootServer({ + events: { enabled: true, notificationsFoldIn: true }, + notificationAppMessageLimits: { perMinute: 0, perDay: 2 }, + } as Partial); + + it("emits notif.created on the app's app-user subject", async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + let uid = ''; + const created = await captureBus( + state.server, + 'notif.created', + async () => { + const result = (await withActor(asApp(holder, app), () => + state.driver.create(message()), + )) as Record; + uid = result.uid as string; + }, + ); + + expect(created).toEqual([ + expect.objectContaining({ + userId: holder.user.id, + uid, + type: 'app.message', + audience: 'app-user', + appUid: app.uid, + }), + ]); + expect( + findNotificationType('app.message')!.subject({ + userUuid: holder.user.uuid!, + appUid: app.uid, + }), + ).toBe(`notif:${app.uid}:app-user`); + }); + + it('limits each app per user per day, with no minute bound configured', async () => { + const holder = await makeUser(state.server); + const app = await makeApp(state.server, holder.user.id!); + for (let i = 0; i < 2; i++) { + await withActor(asApp(holder, app), () => + state.driver.create(message()), + ); + } + await expect( + withActor(asApp(holder, app), () => state.driver.create(message())), + ).rejects.toMatchObject({ statusCode: 429 }); + }); +}); diff --git a/src/backend/drivers/notification/NotificationDriver.test.ts b/src/backend/drivers/notification/NotificationDriver.test.ts index e27d8e23b..92a9e8a04 100644 --- a/src/backend/drivers/notification/NotificationDriver.test.ts +++ b/src/backend/drivers/notification/NotificationDriver.test.ts @@ -222,7 +222,7 @@ describe('NotificationDriver.create', () => { ).rejects.toMatchObject({ statusCode: 400 }); }); - it('rejects an app-actor with 403', async () => { + it('rejects an app creating anything but an app message with 403', async () => { const { actor } = await makeUser(); const appActor = makeActor({ user: actor.user, @@ -237,7 +237,7 @@ describe('NotificationDriver.create', () => { ).rejects.toMatchObject({ statusCode: 403 }); }); - it('rejects a token an app issued with 403', async () => { + it('rejects a token an app issued creating another type with 403', async () => { const { actor } = await makeUser(); const issued = asIssuedToken( actor, diff --git a/src/backend/drivers/notification/NotificationDriver.ts b/src/backend/drivers/notification/NotificationDriver.ts index 928b1b86c..97e0bc80a 100644 --- a/src/backend/drivers/notification/NotificationDriver.ts +++ b/src/backend/drivers/notification/NotificationDriver.ts @@ -23,11 +23,17 @@ import { type UserActor, } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; +import { checkRateLimit } from '../../core/http/middleware/rateLimit.js'; import { resolveNotifFetch } from '../../services/events/notifFetch.js'; import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, } from '../../services/metering/consts.js'; +import { + APP_MESSAGE_RATE_LIMITS, + APP_MESSAGE_TYPE, + parseAppMessage, +} from '../../services/notification/appMessage.js'; import { APP_READABLE_AUDIENCES, canViewNotification, @@ -45,6 +51,8 @@ import type { DriverRateLimitConfig } from '../meta.js'; import { CRUD_CONCURRENT } from '../util/crudLimits.js'; const MAX_SELECT_LIMIT = 200; +const MINUTE_MS = 60_000; +const DAY_MS = 24 * 60 * MINUTE_MS; /** The mailbox slice a call named, in the terms the query takes it. */ interface MailboxScope { @@ -53,6 +61,12 @@ interface MailboxScope { appUid: string | null | undefined; } +const typeNotAllowed = (message: string): HttpError => + new HttpError(403, message, { + legacyCode: 'forbidden', + code: 'notification_type_not_allowed', + }); + /** Whether a row belongs to the slice, as the same SQL scope selected it. */ const inScope = ( row: Record, @@ -82,7 +96,9 @@ const inScope = ( * owns the app. `account` rows never reach an actor holding an app, and no * grant changes that. A slice an actor may not see comes back empty rather * than refused — the mailbox is not an oracle. - * - `create` stays holder-only. + * - The holder creates any registered type but `app.message`, silently. An actor + * holding an app creates `app.message` only, for its own user, and it is + * delivered live. * * `read` and `select` also take an optional `subject` naming one slice * (`notif::`); the two-segment form expands from the actor's @@ -125,7 +141,7 @@ export class NotificationDriver extends PuterDriver { legacyCode: 'bad_request', }); } - const actor = this.#requireUserActor(); + const actor = this.#requireActor(); const value = (object.value ?? {}) as Record; const type = value.type; @@ -145,6 +161,18 @@ export class NotificationDriver extends PuterDriver { ); } + if (actor.effectiveApp) { + return this.#createAppMessage( + actor, + actor.effectiveApp.uid, + object, + value, + ); + } + if (type === APP_MESSAGE_TYPE) { + throw typeNotAllowed(`Only apps can create ${APP_MESSAGE_TYPE}`); + } + const appUid = (object.appUid ?? null) as string | null; // The registry rejects with a plain Error — unregistered, or a type // wrong for the audience it names. Both are caller input, so neither @@ -165,23 +193,11 @@ export class NotificationDriver extends PuterDriver { // Silent: a driver `create` has never pushed to the live socket, only // ever written the row — moving through the registry does not change // that. - const uid = await this.services.notification.notify( - [actor.user.id], - value, - { type: registeredType, appUid, silent: true }, - ); - if (!uid) { - throw new HttpError(500, 'Notification could not be created', { - legacyCode: 'internal_error', - }); - } - // `notify` hands back only the uid; the primary has the rest. - return this.#toClient( - await this.stores.notification.getByUid(uid, { - userId: actor.user.id, - primary: true, - }), - ); + return this.#write(actor, value, { + type: registeredType, + appUid, + silent: true, + }); } async read(args: Record): Promise { @@ -273,6 +289,98 @@ export class NotificationDriver extends PuterDriver { return { success: !!ok }; } + /** + * An app's message to the user it acts for. The recipient and the app are + * the actor's, never the caller's to name. + */ + async #createAppMessage( + actor: UserActor, + appUid: string, + object: Record, + value: Record, + ): Promise { + const { type, ...fields } = value; + if (type !== APP_MESSAGE_TYPE) { + throw typeNotAllowed(`Apps can create only ${APP_MESSAGE_TYPE}`); + } + for (const key of Object.keys(object)) { + if (key !== 'value') { + throw new HttpError( + 400, + `\`${key}\` is set by the server for app notifications`, + { legacyCode: 'bad_request', fields: { field: key } }, + ); + } + } + const message = parseAppMessage(fields); + + if (!(await this.#withinAppMessageLimits(actor.user.id, appUid))) { + throw new HttpError(429, 'Too many notifications from this app.', { + legacyCode: 'too_many_requests', + }); + } + + return this.#write(actor, message, { + type: APP_MESSAGE_TYPE, + appUid, + silent: false, + }); + } + + /** Both windows, each in its own bucket per user and app. */ + async #withinAppMessageLimits( + userId: number, + appUid: string, + ): Promise { + const configured = this.config.notificationAppMessageLimits ?? {}; + const windows: Array<[string, number, number]> = [ + [ + 'minute', + configured.perMinute ?? APP_MESSAGE_RATE_LIMITS.perMinute, + MINUTE_MS, + ], + [ + 'day', + configured.perDay ?? APP_MESSAGE_RATE_LIMITS.perDay, + DAY_MS, + ], + ]; + for (const [name, limit, windowMs] of windows) { + if (limit <= 0) continue; + const key = `notif:app-message:${name}:${userId}:${appUid}`; + if (!(await checkRateLimit(key, limit, windowMs))) return false; + } + return true; + } + + async #write( + actor: UserActor, + value: Record, + opts: { + type: NotificationTypeName; + appUid: string | null; + silent: boolean; + }, + ): Promise { + const uid = await this.services.notification.notify( + [actor.user.id], + value, + opts, + ); + if (!uid) { + throw new HttpError(500, 'Notification could not be created', { + legacyCode: 'internal_error', + }); + } + // `notify` hands back only the uid; the primary has the rest. + return this.#toClient( + await this.stores.notification.getByUid(uid, { + userId: actor.user.id, + primary: true, + }), + ); + } + // -- Permissions ------------------------------------------------- #requireActor(): UserActor { @@ -287,16 +395,6 @@ export class NotificationDriver extends PuterDriver { return actor as UserActor; } - /** Writing a notification stays the holder's, per the token table. */ - #requireUserActor(): UserActor { - const actor = this.#requireActor(); - if (actor.effectiveApp) - throw new HttpError(403, 'App actors cannot create notifications', { - legacyCode: 'forbidden', - }); - return actor; - } - /** * The slice a call named, or `null` for the holder's whole mailbox. An * actor holding an app always has a slice — its own — so `null` only ever diff --git a/src/backend/services/notification/appMessage.test.ts b/src/backend/services/notification/appMessage.test.ts new file mode 100644 index 000000000..810c9f8ac --- /dev/null +++ b/src/backend/services/notification/appMessage.test.ts @@ -0,0 +1,145 @@ +/* + * 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 { parseAppMessage } from './appMessage.js'; + +/** The 400 a field failing validation raises. */ +const rejects = (input: Record, field: string) => + expect(() => parseAppMessage(input)).toThrow( + expect.objectContaining({ + statusCode: 400, + legacyCode: 'bad_request', + fields: { field }, + }), + ); + +describe('parseAppMessage', () => { + it('fills in the defaults around a bare title', () => { + expect(parseAppMessage({ title: ' Hello ' })).toEqual({ + title: 'Hello', + priority: 'normal', + ttl: 3600, + }); + }); + + it('keeps every field of a full message', () => { + const input = { + title: 'New reply', + text: 'not markup, stored as text', + icon: 'https://example.com/icon.png', + target: '/chat/42?from=notif#latest', + data: { thread: 42, tags: ['a'] }, + group: 'chat:42', + channel: 'replies', + sensitive: true, + priority: 'normal', + ttl: 86_400, + }; + expect(parseAppMessage(input)).toEqual(input); + }); + + it('requires a non-empty string title', () => { + rejects({}, 'title'); + rejects({ title: 7 }, 'title'); + rejects({ title: ' ' }, 'title'); + }); + + it('caps the title at 120 characters, counting an emoji once', () => { + expect(parseAppMessage({ title: '😀'.repeat(120) }).title).toBe( + '😀'.repeat(120), + ); + rejects({ title: 'x'.repeat(121) }, 'title'); + }); + + it('caps text at 240 characters', () => { + expect( + parseAppMessage({ title: 't', text: 'x'.repeat(240) }).text, + ).toHaveLength(240); + rejects({ title: 't', text: 'x'.repeat(241) }, 'text'); + rejects({ title: 't', text: 5 }, 'text'); + }); + + it('takes only an https icon', () => { + rejects({ title: 't', icon: 'http://example.com/a.png' }, 'icon'); + rejects({ title: 't', icon: 'data:image/png;base64,AAAA' }, 'icon'); + rejects({ title: 't', icon: 'javascript:alert(1)' }, 'icon'); + rejects({ title: 't', icon: '/relative.png' }, 'icon'); + rejects( + { title: 't', icon: `https://example.com/${'x'.repeat(2048)}` }, + 'icon', + ); + }); + + it('takes only a path inside the app as the target', () => { + rejects({ title: 't', target: 'chat/42' }, 'target'); + rejects({ title: 't', target: 'https://example.com/' }, 'target'); + rejects({ title: 't', target: '//example.com/' }, 'target'); + rejects({ title: 't', target: '/\\example.com/' }, 'target'); + rejects({ title: 't', target: '/a b' }, 'target'); + rejects({ title: 't', target: `/${'x'.repeat(512)}` }, 'target'); + }); + + it('caps serialized data at 1024 bytes', () => { + const fits = { blob: 'x'.repeat(1024 - '{"blob":""}'.length) }; + expect(parseAppMessage({ title: 't', data: fits }).data).toEqual(fits); + rejects({ title: 't', data: { blob: 'x'.repeat(1024) } }, 'data'); + }); + + it('limits group to 64 characters of a small charset', () => { + expect( + parseAppMessage({ title: 't', group: 'Chat_1.thread:2-a' }).group, + ).toBe('Chat_1.thread:2-a'); + rejects({ title: 't', group: 'has space' }, 'group'); + rejects({ title: 't', group: '' }, 'group'); + rejects({ title: 't', group: 'x'.repeat(65) }, 'group'); + }); + + it('limits channel to 32 lowercase letters, digits and dashes', () => { + expect( + parseAppMessage({ title: 't', channel: 'new-reply-2' }).channel, + ).toBe('new-reply-2'); + rejects({ title: 't', channel: 'Replies' }, 'channel'); + rejects({ title: 't', channel: 'a_b' }, 'channel'); + rejects({ title: 't', channel: 'x'.repeat(33) }, 'channel'); + }); + + it('takes a boolean for sensitive', () => { + rejects({ title: 't', sensitive: 'yes' }, 'sensitive'); + }); + + it('refuses high priority to apps', () => { + expect(() => parseAppMessage({ title: 't', priority: 'high' })).toThrow( + "'high' is not available to apps", + ); + rejects({ title: 't', priority: 'urgent' }, 'priority'); + }); + + it('bounds ttl to whole seconds within a day', () => { + expect(parseAppMessage({ title: 't', ttl: 1 }).ttl).toBe(1); + rejects({ title: 't', ttl: 0 }, 'ttl'); + rejects({ title: 't', ttl: 86_401 }, 'ttl'); + rejects({ title: 't', ttl: 1.5 }, 'ttl'); + rejects({ title: 't', ttl: '60' }, 'ttl'); + }); + + it('rejects a field the format does not have', () => { + rejects({ title: 't', body: 'x' }, 'body'); + }); +}); diff --git a/src/backend/services/notification/appMessage.ts b/src/backend/services/notification/appMessage.ts new file mode 100644 index 000000000..9db0ebf85 --- /dev/null +++ b/src/backend/services/notification/appMessage.ts @@ -0,0 +1,244 @@ +/* + * 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 { HttpError } from '../../core/http/HttpError.js'; + +/** The one notification type an app may create. */ +export const APP_MESSAGE_TYPE = 'app.message'; + +export const APP_MESSAGE_TITLE_MAX = 120; +export const APP_MESSAGE_TEXT_MAX = 240; +export const APP_MESSAGE_ICON_MAX = 2048; +export const APP_MESSAGE_TARGET_MAX = 512; +export const APP_MESSAGE_DATA_MAX_BYTES = 1024; +export const APP_MESSAGE_GROUP_MAX = 64; +export const APP_MESSAGE_CHANNEL_MAX = 32; +export const APP_MESSAGE_TTL_MAX_SECONDS = 24 * 60 * 60; +export const APP_MESSAGE_TTL_DEFAULT_SECONDS = 60 * 60; + +/** Creates one app may make for one user, per window. */ +export const APP_MESSAGE_RATE_LIMITS = { + perMinute: 30, + perDay: 300, +} as const; + +export type AppMessagePriority = 'normal'; + +/** An `app.message` value as stored, after normalization. */ +export type AppMessage = { + title: string; + text?: string; + /** An https URL. */ + icon?: string; + /** A path inside the app, opened when the message is followed. */ + target?: string; + data?: unknown; + group?: string; + channel?: string; + sensitive?: boolean; + priority: AppMessagePriority; + ttl: number; +}; + +const FIELDS = new Set([ + 'title', + 'text', + 'icon', + 'target', + 'data', + 'group', + 'channel', + 'sensitive', + 'priority', + 'ttl', +]); + +const GROUP_PATTERN = /^[A-Za-z0-9._:-]+$/u; +const CHANNEL_PATTERN = /^[a-z0-9-]+$/u; +const CONTROL_OR_SPACE = /[\u0000- \u007f\\]/u; + +const invalid = (field: string, message: string): HttpError => + new HttpError(400, `\`${field}\` ${message}`, { + legacyCode: 'bad_request', + fields: { field }, + }); + +/** Length in characters rather than UTF-16 units, so an emoji counts once. */ +const charLength = (value: string): number => [...value].length; + +const optionalString = ( + input: Record, + field: string, + max: number, +): string | undefined => { + const value = input[field]; + if (value === undefined) return undefined; + if (typeof value !== 'string') throw invalid(field, 'must be a string'); + if (charLength(value) > max) { + throw invalid(field, `may not exceed ${max} characters`); + } + return value; +}; + +const parseIcon = (input: Record): string | undefined => { + const icon = optionalString(input, 'icon', APP_MESSAGE_ICON_MAX); + if (icon === undefined) return undefined; + let parsed: URL; + try { + parsed = new URL(icon); + } catch { + throw invalid('icon', 'must be an https URL'); + } + if (parsed.protocol !== 'https:') { + throw invalid('icon', 'must be an https URL'); + } + return icon; +}; + +const parseTarget = (input: Record): string | undefined => { + const target = optionalString(input, 'target', APP_MESSAGE_TARGET_MAX); + if (target === undefined) return undefined; + // `//host` and `/\host` both read as another origin to a browser. + if ( + !target.startsWith('/') || + target.startsWith('//') || + CONTROL_OR_SPACE.test(target) + ) { + throw invalid('target', "must be a path within the app, like '/a/b'"); + } + return target; +}; + +const parsePattern = ( + input: Record, + field: string, + max: number, + pattern: RegExp, + allowed: string, +): string | undefined => { + const value = optionalString(input, field, max); + if (value === undefined) return undefined; + if (!pattern.test(value)) { + throw invalid(field, `may contain only ${allowed}`); + } + return value; +}; + +const parseTtl = (input: Record): number => { + const ttl = input.ttl; + if (ttl === undefined) return APP_MESSAGE_TTL_DEFAULT_SECONDS; + if ( + typeof ttl !== 'number' || + !Number.isInteger(ttl) || + ttl <= 0 || + ttl > APP_MESSAGE_TTL_MAX_SECONDS + ) { + throw invalid( + 'ttl', + `must be a whole number of seconds from 1 to ${APP_MESSAGE_TTL_MAX_SECONDS}`, + ); + } + return ttl; +}; + +/** + * Validate an app's message and return it as stored. Throws a 400 naming the + * first field that fails; unknown fields fail too, so the format only grows on + * purpose. Text is stored as given and escaped where it is shown. + */ +export const parseAppMessage = (input: Record): AppMessage => { + for (const key of Object.keys(input)) { + if (!FIELDS.has(key)) throw invalid(key, 'is not a known field'); + } + + const rawTitle = input.title; + if (typeof rawTitle !== 'string') { + throw invalid('title', 'is required and must be a string'); + } + const title = rawTitle.trim(); + if (title === '') throw invalid('title', 'may not be empty'); + if (charLength(title) > APP_MESSAGE_TITLE_MAX) { + throw invalid( + 'title', + `may not exceed ${APP_MESSAGE_TITLE_MAX} characters`, + ); + } + + const message: AppMessage = { + title, + priority: 'normal', + ttl: parseTtl(input), + }; + + const text = optionalString(input, 'text', APP_MESSAGE_TEXT_MAX); + if (text !== undefined) message.text = text; + const icon = parseIcon(input); + if (icon !== undefined) message.icon = icon; + const target = parseTarget(input); + if (target !== undefined) message.target = target; + + if (input.data !== undefined) { + const serialized = JSON.stringify(input.data); + if ( + serialized === undefined || + Buffer.byteLength(serialized, 'utf8') > APP_MESSAGE_DATA_MAX_BYTES + ) { + throw invalid( + 'data', + `must be JSON of at most ${APP_MESSAGE_DATA_MAX_BYTES} bytes`, + ); + } + message.data = input.data; + } + + const group = parsePattern( + input, + 'group', + APP_MESSAGE_GROUP_MAX, + GROUP_PATTERN, + 'letters, digits and . _ : -', + ); + if (group !== undefined) message.group = group; + const channel = parsePattern( + input, + 'channel', + APP_MESSAGE_CHANNEL_MAX, + CHANNEL_PATTERN, + 'lowercase letters, digits and -', + ); + if (channel !== undefined) message.channel = channel; + + if (input.sensitive !== undefined) { + if (typeof input.sensitive !== 'boolean') { + throw invalid('sensitive', 'must be a boolean'); + } + message.sensitive = input.sensitive; + } + + if (input.priority !== undefined && input.priority !== 'normal') { + throw invalid( + 'priority', + input.priority === 'high' + ? "'high' is not available to apps; use 'normal'" + : "must be 'normal'", + ); + } + + return message; +}; diff --git a/src/backend/services/notification/notificationTypes.test.ts b/src/backend/services/notification/notificationTypes.test.ts index 8f86a52da..3e4e2df88 100644 --- a/src/backend/services/notification/notificationTypes.test.ts +++ b/src/backend/services/notification/notificationTypes.test.ts @@ -73,6 +73,11 @@ describe('NOTIFICATION_TYPES', () => { 'notif:app-1:app-user', ); + const message = findNotificationType('app.message')!; + expect( + message.subject({ userUuid: 'user-uuid', appUid: 'app-1' }), + ).toBe('notif:app-1:app-user'); + // A worker bound to no app has no app to name. const worker = findNotificationType('app.worker.deployed')!; expect(worker.subject({ userUuid: 'user-uuid', appUid: null })).toBe( @@ -122,6 +127,15 @@ describe('resolveNotificationWrite', () => { ).toThrow('requires an app uid'); }); + it('requires an app uid for an app message', () => { + expect(resolveNotificationWrite('app.message', 'app-1')).toMatchObject( + { audience: 'app-user', appScoped: true, groupable: false }, + ); + expect(() => resolveNotificationWrite('app.message', null)).toThrow( + 'requires an app uid', + ); + }); + it('lets a subscription an account made end without naming an app', () => { expect(resolveNotificationWrite('app.events.ended', null).audience).toBe( 'app-user', diff --git a/src/backend/services/notification/notificationTypes.ts b/src/backend/services/notification/notificationTypes.ts index e5b0062bc..f6017ba61 100644 --- a/src/backend/services/notification/notificationTypes.ts +++ b/src/backend/services/notification/notificationTypes.ts @@ -109,6 +109,15 @@ export const NOTIFICATION_TYPES = [ groupable: false, subject: subjectFor('developer'), }, + // The one type an app may create, and only for the user it acts for. What + // kind of message it is goes in the value's `channel`, not a new type. + { + type: 'app.message', + audience: 'app-user', + appScoped: true, + groupable: false, + subject: subjectFor('app-user'), + }, ] as const satisfies readonly NotificationType[]; /** Every registered type name; what `notify` accepts. */ diff --git a/src/backend/types.ts b/src/backend/types.ts index 28206fbbc..f9c1ad29e 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -971,6 +971,16 @@ interface IConfigOptional { */ notificationRetentionDays?: number; + /** + * How many `app.message` notifications one app may create for one user. + * Omit a field for the built-in default (30 per minute, 300 per day); a + * non-positive value removes that bound. + */ + notificationAppMessageLimits?: { + perMinute?: number; + perDay?: number; + }; + // -- Storage / S3 ------------------------------------------------ /** S3 storage config (local fauxqs or remote). */ diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 1ec1b1238..95c5b28a9 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -385,6 +385,24 @@ Over these, **the share still succeeds**; only the announcement is skipped. The Recipients can opt out of share email with the link in the email, and a deployment can turn it off with `share_email_notifications: false`. Recipients can also block shares from one sender or from everyone (**Settings → Security → Blocked people**); the sender's `share` then fails with `recipient_not_accepting_shares`. +### Notifications + +An app, its workers and the tokens it issues can send the user they act for an `app.message` notification. Each app has its own budget per user: + +| Limit | Value | Scope | +| -------------------------------- | --------------------------------- | ----------------- | +| `app.message` notifications | 30/min, 300/day | Per user, per app | +| `title` | 120 characters | | +| `text` | 240 characters | | +| `icon` (an `https` URL) | 2,048 characters | | +| `target` (a path inside the app) | 512 characters | | +| `data` | 1 KiB, measured as JSON | | +| `group` | 64 characters | | +| `channel` | 32 characters | | +| `ttl` | 1 second to 1 day; default 1 hour | | + +Over the rate limit the call fails with `429 too_many_requests`. A value over any size limit, or with a field the format doesn't have, fails with `400 bad_request`. A deployment can change the rate limits with `notificationAppMessageLimits: { perMinute, perDay }`. + ### Teams Available only where the deployment has teams turned on; elsewhere `puter.teams` rejects with `not_found`.