mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-07 06:23:05 +00:00
feat(push): device endpoints for the signed-in user
POST, GET and DELETE /push/devices on the api subdomain let a signed-in Puter session register, list and remove its account's push devices. Apps, access tokens and worker sessions get 403. Registration answers 503 push_disabled unless push.enabled is set; listing and removal always work. An account keeps at most 20 devices, and a new one removes the least recently seen. Responses never include the device token.
This commit is contained in:
1 parent
61ed1ece42
commit
a17ace4187
7 files changed
+734
No files matched your search
@@ -30,6 +30,7 @@ import { HostingController } from './hosting/HostingController.js';
|
||||
import { LegacyFSController } from './fs/LegacyFSController.js';
|
||||
import { NotificationController } from './notification/NotificationController.js';
|
||||
import { OIDCController } from './oidc/OIDCController.js';
|
||||
import { PushController } from './push/PushController.js';
|
||||
import { PuterAIController } from './puterai/PuterAIController.js';
|
||||
import { ShareController } from './share/ShareController.js';
|
||||
import { StaticAssetsController } from './static/StaticAssetsController.js';
|
||||
@@ -57,6 +58,7 @@ export const puterControllers = {
|
||||
drivers: DriverController,
|
||||
broadcast: BroadcastController,
|
||||
notification: NotificationController,
|
||||
push: PushController,
|
||||
events: EventsController,
|
||||
share: ShareController,
|
||||
webdav: WebDAVController,
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
/*
|
||||
* 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
type PuterTestEnv,
|
||||
type TestUserCredentials,
|
||||
} from '../../testUtil.js';
|
||||
import type { PublicPushDevice } from './PushController.js';
|
||||
|
||||
type DeviceBody = { device: PublicPushDevice };
|
||||
type ListBody = { devices: PublicPushDevice[] };
|
||||
|
||||
const call = (
|
||||
env: PuterTestEnv,
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: unknown,
|
||||
) =>
|
||||
fetch(new URL(path, env.apiOrigin), {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'user-agent': 'push-test-agent',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
...(body === undefined || method === 'GET'
|
||||
? {}
|
||||
: { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
let userSeq = 0;
|
||||
const freshUser = (env: PuterTestEnv): Promise<TestUserCredentials> =>
|
||||
createTestUser(env.server, {
|
||||
username: `pushuser${Date.now().toString(36)}${userSeq++}`,
|
||||
password: 'push-test-password',
|
||||
});
|
||||
|
||||
describe('push device routes over HTTP', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv({
|
||||
push: { enabled: true },
|
||||
} as IConfig);
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const register = async (
|
||||
token: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<PublicPushDevice> => {
|
||||
const res = await call(env, 'POST', '/push/devices', token, body);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as DeviceBody).device;
|
||||
};
|
||||
|
||||
const list = async (token: string): Promise<PublicPushDevice[]> => {
|
||||
const res = await call(env, 'GET', '/push/devices', token);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as ListBody).devices;
|
||||
};
|
||||
|
||||
it('registers a device and returns it without the token', async () => {
|
||||
const user = await freshUser(env);
|
||||
const res = await call(env, 'POST', '/push/devices', user.token, {
|
||||
platform: 'android',
|
||||
token: 'fcm-token-register',
|
||||
label: ' Pixel ',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
expect(text).not.toContain('fcm-token-register');
|
||||
expect(text).not.toContain('token_hash');
|
||||
const { device } = JSON.parse(text) as DeviceBody;
|
||||
expect(Object.keys(device).sort()).toEqual(
|
||||
[
|
||||
'created_at',
|
||||
'current',
|
||||
'label',
|
||||
'last_seen_at',
|
||||
'platform',
|
||||
'uid',
|
||||
'user_agent',
|
||||
].sort(),
|
||||
);
|
||||
expect(device).toMatchObject({
|
||||
platform: 'android',
|
||||
label: 'Pixel',
|
||||
user_agent: 'push-test-agent',
|
||||
current: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the same device when its token is registered again', async () => {
|
||||
const user = await freshUser(env);
|
||||
const first = await register(user.token, {
|
||||
platform: 'ios',
|
||||
token: 'apns-token-again',
|
||||
});
|
||||
const second = await register(user.token, {
|
||||
platform: 'ios',
|
||||
token: 'apns-token-again',
|
||||
label: 'iPhone',
|
||||
});
|
||||
|
||||
expect(second.uid).toBe(first.uid);
|
||||
const devices = await list(user.token);
|
||||
expect(devices).toHaveLength(1);
|
||||
expect(devices[0]?.label).toBe('iPhone');
|
||||
});
|
||||
|
||||
it('moves a token to the user who registers it last', async () => {
|
||||
const first = await freshUser(env);
|
||||
const second = await freshUser(env);
|
||||
await register(first.token, { platform: 'web', token: 'moving-token' });
|
||||
await register(second.token, {
|
||||
platform: 'web',
|
||||
token: 'moving-token',
|
||||
});
|
||||
|
||||
expect(await list(first.token)).toHaveLength(0);
|
||||
expect(await list(second.token)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unknown platform', { platform: 'windows', token: 't' }],
|
||||
['a missing platform', { token: 't' }],
|
||||
['an empty token', { platform: 'web', token: '' }],
|
||||
['a non-string token', { platform: 'web', token: 42 }],
|
||||
[
|
||||
'a token over 4096 characters',
|
||||
{ platform: 'web', token: 'a'.repeat(4097) },
|
||||
],
|
||||
['a token with whitespace', { platform: 'web', token: 'abc def' }],
|
||||
[
|
||||
'a token with a control character',
|
||||
{ platform: 'web', token: 'abc\u0007' },
|
||||
],
|
||||
[
|
||||
'a label over 64 characters',
|
||||
{ platform: 'web', token: 't', label: 'l'.repeat(65) },
|
||||
],
|
||||
['a non-string label', { platform: 'web', token: 't', label: 5 }],
|
||||
])('rejects %s with 400', async (_name, body) => {
|
||||
const user = await freshUser(env);
|
||||
const res = await call(env, 'POST', '/push/devices', user.token, body);
|
||||
expect(res.status).toBe(400);
|
||||
expect(await list(user.token)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts a 4096-character token and a 64-character label', async () => {
|
||||
const user = await freshUser(env);
|
||||
const device = await register(user.token, {
|
||||
platform: 'web',
|
||||
token: 'b'.repeat(4096),
|
||||
label: 'l'.repeat(64),
|
||||
});
|
||||
expect(device.label).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('refuses anonymous callers with 401', async () => {
|
||||
for (const [method, path] of [
|
||||
['POST', '/push/devices'],
|
||||
['GET', '/push/devices'],
|
||||
['DELETE', '/push/devices/some-uid'],
|
||||
] as const) {
|
||||
const res = await call(env, method, path, undefined, {
|
||||
platform: 'web',
|
||||
token: 'anon',
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses access tokens and worker sessions with 403', async () => {
|
||||
const user = await freshUser(env);
|
||||
for (const token of [user.apiToken, user.workerToken]) {
|
||||
for (const [method, path] of [
|
||||
['POST', '/push/devices'],
|
||||
['GET', '/push/devices'],
|
||||
['DELETE', '/push/devices/some-uid'],
|
||||
] as const) {
|
||||
const res = await call(env, method, path, token, {
|
||||
platform: 'web',
|
||||
token: 'delegated',
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
}
|
||||
expect(await list(user.token)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses app tokens with 403', async () => {
|
||||
const user = await freshUser(env);
|
||||
const row = await env.server.stores.user.getByUsername(user.username);
|
||||
const actor = makeActor({ user: row! });
|
||||
const app = await env.server.stores.app.create(
|
||||
{
|
||||
name: `push-app-${crypto.randomUUID()}`,
|
||||
title: 'Push app',
|
||||
index_url: `https://push-${crypto.randomUUID()}.test/`,
|
||||
},
|
||||
{ ownerUserId: row!.id },
|
||||
);
|
||||
const appToken = await env.server.services.auth.getUserAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
);
|
||||
|
||||
for (const [method, path] of [
|
||||
['POST', '/push/devices'],
|
||||
['GET', '/push/devices'],
|
||||
['DELETE', '/push/devices/some-uid'],
|
||||
] as const) {
|
||||
const res = await call(env, method, path, appToken, {
|
||||
platform: 'web',
|
||||
token: 'from-app',
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
}
|
||||
expect(await list(user.token)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps 20 devices, dropping the least recently seen for a new one', async () => {
|
||||
const user = await freshUser(env);
|
||||
const uids: string[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const d = await register(user.token, {
|
||||
platform: 'android',
|
||||
token: `cap-token-${i}`,
|
||||
});
|
||||
uids.push(d.uid);
|
||||
}
|
||||
// Device 5 is the stalest; device 0 was seen most recently.
|
||||
for (const [i, uid] of uids.entries()) {
|
||||
await env.server.clients.db.write(
|
||||
'UPDATE `push_device` SET `last_seen_at` = ? WHERE `uid` = ?',
|
||||
[i === 5 ? 1_000 : i === 0 ? 3_000 : 2_000, uid],
|
||||
);
|
||||
}
|
||||
|
||||
await register(user.token, {
|
||||
platform: 'android',
|
||||
token: 'cap-token-0',
|
||||
});
|
||||
expect(await list(user.token)).toHaveLength(20);
|
||||
|
||||
await register(user.token, {
|
||||
platform: 'android',
|
||||
token: 'cap-token-20',
|
||||
});
|
||||
const after = (await list(user.token)).map((d) => d.uid);
|
||||
expect(after).toHaveLength(20);
|
||||
expect(after).not.toContain(uids[5]);
|
||||
expect(after).toContain(uids[0]);
|
||||
});
|
||||
|
||||
it('lists only the caller’s devices and marks the current session', async () => {
|
||||
const user = await freshUser(env);
|
||||
const other = await freshUser(env);
|
||||
const row = await env.server.stores.user.getByUsername(user.username);
|
||||
const { token: secondSession } =
|
||||
await env.server.services.auth.createSessionToken(row!, {
|
||||
user_agent: 'second-session',
|
||||
});
|
||||
|
||||
const here = await register(user.token, {
|
||||
platform: 'web',
|
||||
token: 'list-a',
|
||||
});
|
||||
const there = await register(secondSession, {
|
||||
platform: 'ios',
|
||||
token: 'list-b',
|
||||
});
|
||||
await register(other.token, { platform: 'web', token: 'list-c' });
|
||||
|
||||
const devices = await list(user.token);
|
||||
expect(devices.map((d) => d.uid).sort()).toEqual(
|
||||
[here.uid, there.uid].sort(),
|
||||
);
|
||||
expect(devices.find((d) => d.uid === here.uid)?.current).toBe(true);
|
||||
expect(devices.find((d) => d.uid === there.uid)?.current).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the caller’s own device', async () => {
|
||||
const user = await freshUser(env);
|
||||
const device = await register(user.token, {
|
||||
platform: 'web',
|
||||
token: 'remove-own',
|
||||
});
|
||||
|
||||
const res = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/push/devices/${device.uid}`,
|
||||
user.token,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toEqual({ removed: true });
|
||||
expect(await list(user.token)).toHaveLength(0);
|
||||
|
||||
const again = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/push/devices/${device.uid}`,
|
||||
user.token,
|
||||
);
|
||||
expect(again.status).toBe(404);
|
||||
});
|
||||
|
||||
it('answers 404 for another user’s device and leaves it in place', async () => {
|
||||
const owner = await freshUser(env);
|
||||
const stranger = await freshUser(env);
|
||||
const device = await register(owner.token, {
|
||||
platform: 'web',
|
||||
token: 'remove-other',
|
||||
});
|
||||
|
||||
const res = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/push/devices/${device.uid}`,
|
||||
stranger.token,
|
||||
);
|
||||
const missing = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
'/push/devices/no-such-uid',
|
||||
stranger.token,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(missing.status).toBe(404);
|
||||
expect(await res.json()).toEqual(await missing.json());
|
||||
expect((await list(owner.token)).map((d) => d.uid)).toEqual([
|
||||
device.uid,
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops a device when the session that registered it signs out', async () => {
|
||||
const user = await freshUser(env);
|
||||
const row = await env.server.stores.user.getByUsername(user.username);
|
||||
const { token: leaving } =
|
||||
await env.server.services.auth.createSessionToken(row!, {
|
||||
user_agent: 'leaving-session',
|
||||
});
|
||||
const gone = await register(leaving, { platform: 'ios', token: 'bye' });
|
||||
const kept = await register(user.token, {
|
||||
platform: 'web',
|
||||
token: 'hi',
|
||||
});
|
||||
|
||||
await env.server.services.auth.removeSessionByToken(leaving);
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const uids = (await list(user.token)).map((d) => d.uid);
|
||||
expect(uids).not.toContain(gone.uid);
|
||||
expect(uids).toContain(kept.uid);
|
||||
});
|
||||
});
|
||||
|
||||
it('answers 429 past 30 registrations a minute', async () => {
|
||||
const user = await freshUser(env);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const res = await call(env, 'POST', '/push/devices', user.token, {
|
||||
platform: 'web',
|
||||
token: 'rate-token',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
const res = await call(env, 'POST', '/push/devices', user.token, {
|
||||
platform: 'web',
|
||||
token: 'rate-token',
|
||||
});
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('push device routes with push disabled', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
it('refuses registration with 503 push_disabled', async () => {
|
||||
const res = await call(
|
||||
env,
|
||||
'POST',
|
||||
'/push/devices',
|
||||
env.users.user.token,
|
||||
{
|
||||
platform: 'web',
|
||||
token: 'disabled-token',
|
||||
},
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.json()).toMatchObject({ code: 'push_disabled' });
|
||||
});
|
||||
|
||||
it('still lists and removes devices', async () => {
|
||||
const row = await env.server.stores.user.getByUsername(
|
||||
env.users.user.username,
|
||||
);
|
||||
const device = await env.server.services.push.registerDevice({
|
||||
userId: row!.id,
|
||||
sessionUid: 'session-from-before',
|
||||
platform: 'android',
|
||||
token: 'kept-from-before',
|
||||
label: null,
|
||||
userAgent: null,
|
||||
});
|
||||
|
||||
const listed = await call(
|
||||
env,
|
||||
'GET',
|
||||
'/push/devices',
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(listed.status).toBe(200);
|
||||
expect(
|
||||
((await listed.json()) as ListBody).devices.map((d) => d.uid),
|
||||
).toEqual([device.uid]);
|
||||
|
||||
const removed = await call(
|
||||
env,
|
||||
'DELETE',
|
||||
`/push/devices/${device.uid}`,
|
||||
env.users.user.token,
|
||||
);
|
||||
expect(removed.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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, Response } from 'express';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { bodyRecord } from '../../core/http/requestBody.js';
|
||||
import type {
|
||||
PushDevice,
|
||||
PushPlatform,
|
||||
} from '../../stores/push/PushDeviceStore.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const PLATFORMS: ReadonlySet<string> = new Set<PushPlatform>([
|
||||
'web',
|
||||
'ios',
|
||||
'android',
|
||||
]);
|
||||
const TOKEN_MAX_LENGTH = 4096;
|
||||
const LABEL_MAX_LENGTH = 64;
|
||||
const USER_AGENT_MAX_LENGTH = 256;
|
||||
const LIST_LIMIT = 50;
|
||||
const WHITESPACE_OR_CONTROL = /[\s\p{Cc}]/u;
|
||||
|
||||
/** A device as the routes return it; the provider token never leaves. */
|
||||
export interface PublicPushDevice {
|
||||
uid: string;
|
||||
platform: PushPlatform;
|
||||
label: string | null;
|
||||
user_agent: string | null;
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
/** Registered from the session making this request. */
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
const badRequest = (message: string) =>
|
||||
new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
|
||||
const toPublicDevice = (
|
||||
device: PushDevice,
|
||||
sessionUid: string,
|
||||
): PublicPushDevice => ({
|
||||
uid: device.uid,
|
||||
platform: device.platform,
|
||||
label: device.label,
|
||||
user_agent: device.userAgent,
|
||||
created_at: device.createdAt,
|
||||
last_seen_at: device.lastSeenAt,
|
||||
current: device.sessionUid === sessionUid,
|
||||
});
|
||||
|
||||
/** Push devices of the signed-in user, for Puter's own web and mobile apps. */
|
||||
@Controller('/push')
|
||||
export class PushController extends PuterController {
|
||||
/** POST /push/devices `{ platform, token, label? }` — add or refresh. */
|
||||
@Post('/devices', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
bodyJson: { limit: '8kb' },
|
||||
rateLimit: {
|
||||
scope: 'push-device-register',
|
||||
limit: 30,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
})
|
||||
async register(req: Request, res: Response): Promise<void> {
|
||||
const { userId, sessionUid } = this.#signedIn(req.actor);
|
||||
if (this.config.push?.enabled !== true) {
|
||||
throw new HttpError(503, 'Push notifications are not enabled', {
|
||||
legacyCode: 'push_disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const body = bodyRecord(req);
|
||||
const { platform, token, label } = body;
|
||||
if (typeof platform !== 'string' || !PLATFORMS.has(platform)) {
|
||||
throw badRequest('`platform` must be one of web, ios, android');
|
||||
}
|
||||
if (
|
||||
typeof token !== 'string' ||
|
||||
token.length === 0 ||
|
||||
token.length > TOKEN_MAX_LENGTH ||
|
||||
WHITESPACE_OR_CONTROL.test(token)
|
||||
) {
|
||||
throw badRequest(
|
||||
`\`token\` must be 1 to ${TOKEN_MAX_LENGTH} characters with no whitespace`,
|
||||
);
|
||||
}
|
||||
if (label !== undefined && label !== null && typeof label !== 'string')
|
||||
throw badRequest('`label` must be a string');
|
||||
const trimmedLabel = typeof label === 'string' ? label.trim() : '';
|
||||
if (trimmedLabel.length > LABEL_MAX_LENGTH) {
|
||||
throw badRequest(
|
||||
`\`label\` must be at most ${LABEL_MAX_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = req
|
||||
.get('user-agent')
|
||||
?.slice(0, USER_AGENT_MAX_LENGTH);
|
||||
const device = await this.services.push.registerDevice({
|
||||
userId,
|
||||
sessionUid,
|
||||
platform: platform as PushPlatform,
|
||||
token,
|
||||
label: trimmedLabel || null,
|
||||
userAgent: userAgent || null,
|
||||
});
|
||||
res.json({ device: toPublicDevice(device, sessionUid) });
|
||||
}
|
||||
|
||||
/** GET /push/devices — the caller's devices, newest first. */
|
||||
@Get('/devices', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'push-device-list',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
})
|
||||
async list(req: Request, res: Response): Promise<void> {
|
||||
const { userId, sessionUid } = this.#signedIn(req.actor);
|
||||
const devices = await this.stores.pushDevice.listByUser(userId, {
|
||||
limit: LIST_LIMIT,
|
||||
});
|
||||
res.json({
|
||||
devices: devices.map((d) => toPublicDevice(d, sessionUid)),
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE /push/devices/:uid — 404 unless the device is the caller's. */
|
||||
@Delete('/devices/:uid', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'push-device-remove',
|
||||
limit: 30,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
})
|
||||
async remove(req: Request, res: Response): Promise<void> {
|
||||
const { userId } = this.#signedIn(req.actor);
|
||||
const uid = String(req.params.uid ?? '');
|
||||
const removed = await this.stores.pushDevice.removeByUid(userId, uid);
|
||||
if (!removed) {
|
||||
throw new HttpError(404, 'Device not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
res.json({ removed: true });
|
||||
}
|
||||
|
||||
#signedIn(actor: Actor | undefined): {
|
||||
userId: number;
|
||||
sessionUid: string;
|
||||
} {
|
||||
const userId = actor?.user?.id;
|
||||
if (!userId) {
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
}
|
||||
const session = actor.session;
|
||||
if (!session?.uid || session.kind !== 'web') {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Devices can only be managed from a signed-in Puter session',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
return { userId, sessionUid: session.uid };
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PushDevice,
|
||||
PushDeviceRegistration,
|
||||
} from '../../stores/push/PushDeviceStore.js';
|
||||
import { PuterService } from '../types.js';
|
||||
|
||||
const DEFAULT_DEVICE_RETENTION_DAYS = 60;
|
||||
@@ -25,6 +29,7 @@ const STALE_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const STALE_BATCH_SIZE = 500;
|
||||
/** Batches one sweep takes, so a large backlog drains over several passes. */
|
||||
const STALE_MAX_BATCHES = 50;
|
||||
const MAX_DEVICES_PER_USER = 20;
|
||||
|
||||
/** Keeps push devices in step with their sessions; runs even with push off. */
|
||||
export class PushService extends PuterService {
|
||||
@@ -62,6 +67,18 @@ export class PushService extends PuterService {
|
||||
this.#staleSweep = null;
|
||||
}
|
||||
|
||||
/** A new device past 20 per user removes the least recently seen. */
|
||||
async registerDevice(
|
||||
registration: PushDeviceRegistration,
|
||||
): Promise<PushDevice> {
|
||||
const device = await this.stores.pushDevice.register(registration);
|
||||
await this.stores.pushDevice.removeOldestBeyond(
|
||||
registration.userId,
|
||||
MAX_DEVICES_PER_USER,
|
||||
);
|
||||
return device;
|
||||
}
|
||||
|
||||
/** Every node sweeps; an overlap costs only empty indexed batches. */
|
||||
async sweepStaleDevices(): Promise<number> {
|
||||
const days = this.#retentionDays();
|
||||
|
||||
@@ -388,6 +388,31 @@ describe('PushDeviceStore', () => {
|
||||
expect(await store().getByUid(others.uid)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps only the most recently seen devices of one user', async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const stalest = await register(user.id);
|
||||
const stale = await register(user.id);
|
||||
const fresh = await register(user.id);
|
||||
const freshest = await register(user.id);
|
||||
const others = await register(other.id);
|
||||
// Later than any cutoff the other tests sweep with.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
await setLastSeen(stalest.uid, now + 1);
|
||||
await setLastSeen(stale.uid, now + 2);
|
||||
await setLastSeen(fresh.uid, now + 3);
|
||||
await setLastSeen(freshest.uid, now + 4);
|
||||
await setLastSeen(others.uid, now);
|
||||
|
||||
expect(await store().removeOldestBeyond(user.id, 4)).toBe(0);
|
||||
expect(await store().removeOldestBeyond(user.id, 2)).toBe(2);
|
||||
const left = await store().listByUser(user.id);
|
||||
expect(left.map((d) => d.uid).sort()).toEqual(
|
||||
[fresh.uid, freshest.uid].sort(),
|
||||
);
|
||||
expect(await store().getByUid(others.uid)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('touches last_seen_at and counts failures', async () => {
|
||||
const user = await makeUser();
|
||||
const device = await register(user.id);
|
||||
|
||||
@@ -290,6 +290,23 @@ export class PushDeviceStore extends PuterStore {
|
||||
return result.affectedRows;
|
||||
}
|
||||
|
||||
/** Remove up to 500 devices past the user's `keep` most recently seen. */
|
||||
async removeOldestBeyond(userId: number, keep: number): Promise<number> {
|
||||
const offset = safeLimit(keep, 0);
|
||||
const rows = await this.clients.db.pread(
|
||||
'SELECT `id` FROM `push_device` WHERE `user_id` = ? ' +
|
||||
'ORDER BY `last_seen_at` DESC, `id` DESC LIMIT ? OFFSET ?',
|
||||
[userId, 500, offset],
|
||||
);
|
||||
const ids = rows.map((row) => Number(row.id));
|
||||
if (ids.length === 0) return 0;
|
||||
const result = await this.clients.db.write(
|
||||
`DELETE FROM \`push_device\` WHERE \`id\` IN (${placeholders(ids)})`,
|
||||
ids,
|
||||
);
|
||||
return result.affectedRows;
|
||||
}
|
||||
|
||||
async touch(userId: number, uids: string[]): Promise<void> {
|
||||
const list = unique(uids);
|
||||
if (list.length === 0) return;
|
||||
|
||||
@@ -595,6 +595,21 @@ Relay traffic a guest sends is billed to the account that issued the grant. Issu
|
||||
|
||||
An account can also hold at most **150** open connections from one origin, so a single page can't use up the whole allowance. Over either limit, the new connection is closed as soon as it opens; connections already open stay up.
|
||||
|
||||
### Push devices
|
||||
|
||||
The Puter desktop and the Puter mobile apps register the devices that receive an account's push notifications. These routes only accept a signed-in Puter session, so apps and API tokens can't call them.
|
||||
|
||||
| Limit | Value |
|
||||
| --------------------------------------------- | ---------------- |
|
||||
| Device registrations (`POST /push/devices`) | 30/min |
|
||||
| Device list reads (`GET /push/devices`) | 60/min |
|
||||
| Device removals (`DELETE /push/devices/:uid`) | 30/min |
|
||||
| Devices per account | 20 |
|
||||
| Device token | 4,096 characters |
|
||||
| Device label | 64 characters |
|
||||
|
||||
Registering a new device when the account already has 20 removes the device that was seen least recently.
|
||||
|
||||
### All driver calls
|
||||
|
||||
Every driver call also counts toward **8,000 calls/min**, before the per-API limits above. It only exists to catch runaway loops; a client that hits it is looping.
|
||||
|
||||
Reference in new issue
Block a user