mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-12 14:50:39 +00:00
Merge remote-tracking branch 'origin/main' into juancastro/full-access-token-route-gates
This commit is contained in:
commit
2f16ee42aa
24 files changed
+812
-65
No files matched your search
@@ -52,7 +52,9 @@ import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
|
||||
import {
|
||||
FS_BATCH_WRITE_MAX_ITEMS,
|
||||
FS_MULTIPART_LIMIT,
|
||||
FS_MULTIPART_MAX_PARTS,
|
||||
FS_MUTATE_LIMIT,
|
||||
FS_READ_CONCURRENT,
|
||||
FS_READ_LIMIT,
|
||||
@@ -222,6 +224,7 @@ export class FSController extends PuterController {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const storageAllowanceMax = this.#getStorageAllowanceMaxOverride(req);
|
||||
const appUidLookupCache = new Map<string, Promise<number | null>>();
|
||||
this.#assertBatchItemCount(req.body);
|
||||
const requests = Array.isArray(req.body)
|
||||
? await Promise.all(
|
||||
req.body.map(async (requestBody) => {
|
||||
@@ -367,6 +370,7 @@ export class FSController extends PuterController {
|
||||
res: Response<ClientCompleteWriteResponse[]>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
this.#assertBatchItemCount(req.body);
|
||||
const requests = Array.isArray(req.body)
|
||||
? req.body.map((requestBody) => {
|
||||
return this.#withGuiMetadata(
|
||||
@@ -443,6 +447,17 @@ export class FSController extends PuterController {
|
||||
res: Response<ClientSignMultipartPartsResponse>,
|
||||
) {
|
||||
const userId = this.#getActorUserId(req);
|
||||
const partNumbers = req.body?.partNumbers;
|
||||
if (
|
||||
Array.isArray(partNumbers) &&
|
||||
partNumbers.length > FS_MULTIPART_MAX_PARTS
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Too many partNumbers in one request (max ${FS_MULTIPART_MAX_PARTS})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
await this.#assertUploadSessionWriteAccess(req, userId, [
|
||||
req.body?.uploadId,
|
||||
]);
|
||||
@@ -863,6 +878,7 @@ export class FSController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#assertBatchItemCount(req.body);
|
||||
const requests = Array.isArray(req.body)
|
||||
? await Promise.all(
|
||||
req.body.map(async (requestBody) => {
|
||||
@@ -2382,6 +2398,16 @@ export class FSController extends PuterController {
|
||||
return guiMetadata;
|
||||
}
|
||||
|
||||
#assertBatchItemCount(items: unknown): void {
|
||||
if (Array.isArray(items) && items.length > FS_BATCH_WRITE_MAX_ITEMS) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The write handlers build on `req.body` being an object. A request whose
|
||||
* body never parsed leaves it undefined, and the first field read after
|
||||
@@ -3019,6 +3045,7 @@ export class FSController extends PuterController {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
this.#assertBatchItemCount(manifest.items);
|
||||
|
||||
const manifestGuiMetadata = this.#extractGuiMetadata(
|
||||
manifest,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { FSController } from './FSController.js';
|
||||
import { FS_BATCH_WRITE_MAX_ITEMS, FS_MULTIPART_MAX_PARTS } from './limits.js';
|
||||
import type {
|
||||
ClientSignedWriteResponse,
|
||||
CompleteWriteRequest,
|
||||
@@ -661,6 +662,60 @@ describe('batch write handlers with a malformed element', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch write handlers at the item cap', () => {
|
||||
// Null elements fail the per-item body check, so which 400 comes back
|
||||
// shows whether the cap ran before any item was read.
|
||||
const handlers: Array<
|
||||
[string, (c: typeof controller, r: Request, res: Response) => unknown]
|
||||
> = [
|
||||
[
|
||||
'startBatchWrites',
|
||||
(c, r, res) => c.startBatchWrites(r as never, res as never),
|
||||
],
|
||||
['batchWrites', (c, r, res) => c.batchWrites(r as never, res as never)],
|
||||
[
|
||||
'completeBatchWrites',
|
||||
(c, r, res) => c.completeBatchWrites(r as never, res as never),
|
||||
],
|
||||
];
|
||||
|
||||
const callWith = async (
|
||||
call: (typeof handlers)[number][1],
|
||||
length: number,
|
||||
) => {
|
||||
const { actor } = await makeUser();
|
||||
const req = makeReq({ actor });
|
||||
(req as { body?: unknown }).body = Array(length).fill(null);
|
||||
const { res } = makeRes();
|
||||
return withActor(actor, async () => call(controller, req, res));
|
||||
};
|
||||
|
||||
it.each(handlers)(
|
||||
'%s rejects one item over the cap up front',
|
||||
async (_label, call) => {
|
||||
await expect(
|
||||
callWith(call, FS_BATCH_WRITE_MAX_ITEMS + 1),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(handlers)(
|
||||
'%s lets exactly the cap through to the per-item checks',
|
||||
async (_label, call) => {
|
||||
await expect(
|
||||
callWith(call, FS_BATCH_WRITE_MAX_ITEMS),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: 'A request body is required',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('FSController.write storage allowance', () => {
|
||||
let limitedServer: PuterServer;
|
||||
let limitedController: FSController;
|
||||
@@ -1283,6 +1338,29 @@ describe('FSController.signMultipartParts', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
// The session is unknown, so reaching the lookup would answer 404.
|
||||
it.each([
|
||||
[FS_MULTIPART_MAX_PARTS + 1, 400],
|
||||
[FS_MULTIPART_MAX_PARTS, 404],
|
||||
])('answers %i part numbers with %i', async (length, statusCode) => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.signMultipartParts(
|
||||
makeReq<SignMultipartPartsRequest>({
|
||||
body: {
|
||||
uploadId: uuidv4(),
|
||||
partNumbers: Array(length).fill(1),
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode });
|
||||
});
|
||||
});
|
||||
|
||||
// -- /fs/batchWrite (JSON mode) ---------------------------------------
|
||||
@@ -1716,6 +1794,35 @@ describe('FSController.batchWrites (multipart)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a manifest over the item cap', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.batchWrites(
|
||||
makeMultipartReq(
|
||||
[
|
||||
{
|
||||
kind: 'field',
|
||||
name: 'manifest',
|
||||
value: JSON.stringify({
|
||||
items: Array(
|
||||
FS_BATCH_WRITE_MAX_ITEMS + 1,
|
||||
).fill(null),
|
||||
}),
|
||||
},
|
||||
],
|
||||
actor,
|
||||
),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: `Too many items in one request (max ${FS_BATCH_WRITE_MAX_ITEMS})`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a manifest item without fileMetadata', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
|
||||
@@ -33,7 +33,7 @@ import { signFile } from '../../util/fileSigning.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { SHARE_LIST_LIMIT } from '../share/limits.js';
|
||||
import type { LegacyFSController } from './LegacyFSController.js';
|
||||
import { FS_MUTATE_LIMIT } from './limits.js';
|
||||
import { FS_MUTATE_LIMIT, FS_SIGN_MAX_ITEMS } from './limits.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -1920,6 +1920,53 @@ describe('LegacyFSController.sign', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
// The missing app would 404 if any item or the grant were looked at first.
|
||||
it('rejects items over the cap before resolving anything', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.sign(
|
||||
makeReq({
|
||||
body: {
|
||||
items: Array(FS_SIGN_MAX_ITEMS + 1).fill({
|
||||
path: '/x',
|
||||
action: 'read',
|
||||
}),
|
||||
app_uid: `does-not-exist-${uuidv4()}`,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
message: `Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts exactly the cap', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.sign(
|
||||
makeReq({
|
||||
body: {
|
||||
items: Array(FS_SIGN_MAX_ITEMS).fill({
|
||||
action: 'read',
|
||||
}),
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as { signatures: Array<unknown> };
|
||||
expect(body.signatures).toHaveLength(FS_SIGN_MAX_ITEMS);
|
||||
});
|
||||
|
||||
it('signs a valid entry by path and returns a signature', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
FS_SEARCH_CONCURRENT,
|
||||
FS_SEARCH_LIMIT,
|
||||
FS_SIGN_LIMIT,
|
||||
FS_SIGN_MAX_ITEMS,
|
||||
FS_SIGNED_CONCURRENT,
|
||||
FS_SIGNED_READ_LIMIT,
|
||||
FS_SIGNED_WRITE_LIMIT,
|
||||
@@ -1351,6 +1352,12 @@ export class LegacyFSController extends PuterController {
|
||||
throw new HttpError(400, '`items` is required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
if (items.length > FS_SIGN_MAX_ITEMS)
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Too many items in one request (max ${FS_SIGN_MAX_ITEMS})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
|
||||
const actingApp = actor.effectiveApp;
|
||||
const signingCfg = signingConfigFromAppConfig(this.config);
|
||||
|
||||
@@ -28,7 +28,10 @@ import type { RouteOptions, RouteRateLimit } from '../../core/http/types';
|
||||
|
||||
type Spec = RouteRateLimit | NonNullable<RouteOptions['concurrent']>;
|
||||
|
||||
const all = Object.entries(limits) as Array<[string, Spec | RouteRateLimit[]]>;
|
||||
// Rate and concurrency specs only; the per-request item caps are plain numbers.
|
||||
const all = Object.entries(limits).filter(
|
||||
([, value]) => typeof value === 'object',
|
||||
) as Array<[string, Spec | RouteRateLimit[]]>;
|
||||
const flat: Array<[string, Spec]> = all.flatMap(([name, value]) =>
|
||||
Array.isArray(value)
|
||||
? value.map((v, i): [string, Spec] => [`${name}[${i}]`, v])
|
||||
|
||||
@@ -185,6 +185,27 @@ export const FS_HELPER_LIMIT = userWindow('fs:helper', 120, 60, 30);
|
||||
*/
|
||||
export const FS_POLL_LIMIT = userWindow('fs:poll', 240, 240, 120);
|
||||
|
||||
// -- Per-request item caps -------------------------------------------
|
||||
//
|
||||
// The windows above count requests; these bound what one request can fan out
|
||||
// to. Each item costs its own path resolution and ACL check, and an upload item
|
||||
// also a pending session and presigned URLs. Checked before any per-item work.
|
||||
|
||||
/**
|
||||
* `startBatchWrite` / `completeBatchWrite` / `batchWrite`. Puter.js chunks
|
||||
* uploads at this size.
|
||||
*/
|
||||
export const FS_BATCH_WRITE_MAX_ITEMS = 500;
|
||||
|
||||
/** Entries per `/sign`. Each may also write an app grant. */
|
||||
export const FS_SIGN_MAX_ITEMS = 500;
|
||||
|
||||
/**
|
||||
* Part numbers per `signMultipartParts`: every part a multipart upload can have
|
||||
* (1-10,000).
|
||||
*/
|
||||
export const FS_MULTIPART_MAX_PARTS = 10_000;
|
||||
|
||||
// -- Legacy multipart upload -----------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -1156,7 +1156,7 @@ describe('KVStoreDriver', () => {
|
||||
).toBe('from-calendar');
|
||||
});
|
||||
|
||||
it('keeps delete orthogonal to write', async () => {
|
||||
it('lets delete imply write, but not write imply delete', async () => {
|
||||
const write = await setup();
|
||||
await grant(
|
||||
write.owner,
|
||||
@@ -1175,11 +1175,16 @@ describe('KVStoreDriver', () => {
|
||||
del.calendar.uid,
|
||||
appDataPermission(del.contacts.uid, 'kv', 'delete'),
|
||||
);
|
||||
await expect(
|
||||
crossApp(del.calendarActor, del.contacts.uid, (optConfig) =>
|
||||
target.set({ key: 'entry', value: 'nope', optConfig }),
|
||||
await crossApp(del.calendarActor, del.contacts.uid, (optConfig) =>
|
||||
target.set({ key: 'entry', value: 'changed', optConfig }),
|
||||
);
|
||||
expect(
|
||||
await crossApp(
|
||||
del.calendarActor,
|
||||
del.contacts.uid,
|
||||
(optConfig) => target.get({ key: 'entry', optConfig }),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
).toBe('changed');
|
||||
});
|
||||
|
||||
it('permits every delete op with the delete class', async () => {
|
||||
|
||||
@@ -938,7 +938,7 @@ describe('AppPermissionService — app-data cross-app permissions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps delete orthogonal to write', async () => {
|
||||
it('ranks the classes delete > write > read', async () => {
|
||||
const owner = await makeUser();
|
||||
const DELETE_OPS = ['del', 'remove', 'expire', 'expireAt'] as const;
|
||||
|
||||
@@ -959,15 +959,15 @@ describe('AppPermissionService — app-data cross-app permissions', () => {
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// ...and a delete grant covers every deletion without conferring
|
||||
// write, so cancelling an entry doesn't imply rewriting the rest.
|
||||
// ...and a delete grant covers every deletion plus what it implies:
|
||||
// nothing can be removed that couldn't be read or changed.
|
||||
const d = await makeGranteeAndTarget(owner);
|
||||
await grant(
|
||||
owner,
|
||||
d.grantee.uid,
|
||||
appDataPermission(d.target.uid, 'kv', 'delete'),
|
||||
);
|
||||
for (const op of DELETE_OPS) {
|
||||
for (const op of [...DELETE_OPS, 'set', 'update', 'get', 'list']) {
|
||||
expect(
|
||||
await permissions.check(
|
||||
d.granteeActor,
|
||||
@@ -975,12 +975,21 @@ describe('AppPermissionService — app-data cross-app permissions', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
await permissions.check(
|
||||
d.granteeActor,
|
||||
appDataPermission(d.target.uid, 'kv', 'set'),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const f = await makeGranteeAndTarget(owner);
|
||||
await grant(
|
||||
owner,
|
||||
f.grantee.uid,
|
||||
appDataPermission(f.target.uid, 'fs', 'delete'),
|
||||
);
|
||||
for (const cls of ['read', 'write', 'delete']) {
|
||||
expect(
|
||||
await permissions.check(
|
||||
f.granteeActor,
|
||||
appDataPermission(f.target.uid, 'fs', cls),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -285,9 +285,11 @@ export class AppPermissionService extends PuterService {
|
||||
push(cls);
|
||||
}
|
||||
}
|
||||
// fs:read is satisfied by fs:write. `delete` is orthogonal —
|
||||
// it implies neither, and neither implies it.
|
||||
// Same ladder as kv: fs:delete > fs:write > fs:read.
|
||||
if (store === 'fs' && op === 'read') push('write');
|
||||
if (store === 'fs' && (op === 'read' || op === 'write')) {
|
||||
push('delete');
|
||||
}
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,34 +27,33 @@ import { PuterService } from '../types.js';
|
||||
* at call time against the apps table.
|
||||
*/
|
||||
const RECOMMENDED_APP_NAMES = [
|
||||
'app-center',
|
||||
'builder',
|
||||
'editor',
|
||||
'camera',
|
||||
'recorder',
|
||||
'calculator',
|
||||
'contacts',
|
||||
'calendar',
|
||||
'meetings',
|
||||
'blockarena',
|
||||
'music-player',
|
||||
'word-processor',
|
||||
'teamchat',
|
||||
'email',
|
||||
'whiteboard',
|
||||
'spreadsheet',
|
||||
'word-processor',
|
||||
'presentation',
|
||||
'pdf-editor',
|
||||
'diagram',
|
||||
'memos',
|
||||
'audio-editor',
|
||||
'browser',
|
||||
'ai-image-project',
|
||||
'cap-table',
|
||||
'invoices',
|
||||
'crm',
|
||||
'signatures',
|
||||
'projects',
|
||||
'point-of-sale',
|
||||
'inventory',
|
||||
'hiring',
|
||||
'time-tracking',
|
||||
'shift-planner',
|
||||
'equipment',
|
||||
'chess',
|
||||
'checkers',
|
||||
'backgammon',
|
||||
'klondike',
|
||||
'sudoku',
|
||||
'blockup',
|
||||
'basketball-tap',
|
||||
'dev-center',
|
||||
'klondike',
|
||||
];
|
||||
|
||||
export class RecommendedAppsService extends PuterService {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import { CreateBucketCommand } from '@aws-sdk/client-s3';
|
||||
import { CreateBucketCommand, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
@@ -503,6 +503,75 @@ describe('FSService write payload handling', () => {
|
||||
// Generous timeout: the object-store client retries the torn-off body
|
||||
// before giving up, which puts the rejection just past the default.
|
||||
}, 20_000);
|
||||
|
||||
/**
|
||||
* Answer every PutObject the way the real store does when the body ends
|
||||
* before the length it was sent with; the in-memory store doesn't check.
|
||||
*/
|
||||
const rejectShortPuts = () => {
|
||||
const client = server.clients.s3.get(
|
||||
server.stores.s3Object.resolveRegion(null),
|
||||
);
|
||||
const original = client.send.bind(client);
|
||||
return vi.spyOn(client, 'send').mockImplementation((async (
|
||||
command: unknown,
|
||||
) => {
|
||||
if (!(command instanceof PutObjectCommand)) {
|
||||
return original(command as never);
|
||||
}
|
||||
const body = command.input.Body;
|
||||
if (body instanceof Readable) {
|
||||
for await (const _chunk of body) {
|
||||
// drain
|
||||
}
|
||||
}
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
'You did not provide the number of bytes specified by the Content-Length HTTP header',
|
||||
),
|
||||
{ name: 'IncompleteBody', Code: 'IncompleteBody' },
|
||||
);
|
||||
}) as never);
|
||||
};
|
||||
|
||||
it('rejects a stream shorter than its declared size as a bad request', async () => {
|
||||
const send = rejectShortPuts();
|
||||
try {
|
||||
const error = await caught(() =>
|
||||
fs.write(user.userId, {
|
||||
fileMetadata: {
|
||||
path: `${user.home}/Documents/short.bin`,
|
||||
size: 57,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: Readable.from(['x'.repeat(54)]),
|
||||
}),
|
||||
);
|
||||
expect(error.statusCode).toBe(400);
|
||||
expect(error.message).toBe(
|
||||
'File content is shorter than its declared size',
|
||||
);
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
expect(await entryAt(user, '/Documents/short.bin')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a short-body rejection of a measured payload as a server error', async () => {
|
||||
// A buffer's length is measured here, not declared by the caller, so
|
||||
// the store refusing it is not the caller's fault.
|
||||
const send = rejectShortPuts();
|
||||
try {
|
||||
const error = await write('measured.bin', Buffer.from('abc')).then(
|
||||
() => null,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
expect(error).not.toBeInstanceOf(HttpError);
|
||||
expect((error as { name?: string })?.name).toBe('IncompleteBody');
|
||||
} finally {
|
||||
send.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('FSService overwrite and dedupe resolution', () => {
|
||||
|
||||
@@ -53,11 +53,13 @@ import {
|
||||
} from '../../stores/fs/FSEntry.js';
|
||||
import {
|
||||
clampSignedUploadExpirySeconds,
|
||||
isIncompleteBodyError,
|
||||
isMissingObjectError,
|
||||
} from '../../stores/fs/S3ObjectStore.js';
|
||||
import { toUploadReservationBytes } from '../../stores/fs/UploadReservationStore.js';
|
||||
import type {
|
||||
MultipartCompletePart,
|
||||
ServerUploadInput,
|
||||
SignedUploadResult,
|
||||
} from '../../stores/fs/s3Types.js';
|
||||
import type { puterStores } from '../../stores/index.js';
|
||||
@@ -1533,6 +1535,30 @@ export class FSService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
async #uploadContent(
|
||||
input: ServerUploadInput,
|
||||
region: string,
|
||||
uploadBody: UploadPayload,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.stores.s3Object.uploadFromServer(input, region);
|
||||
} catch (error) {
|
||||
// A stream is sent with the caller's declared size as its length,
|
||||
// so a body that ends short of it is a bad request.
|
||||
if (
|
||||
uploadBody.contentLength === undefined &&
|
||||
isIncompleteBodyError(error)
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'File content is shorter than its declared size',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #cleanupPreparedBatchUploads(
|
||||
preparedBatch: PreparedBatchWrite,
|
||||
uploadedItems: UploadedBatchWriteItem[],
|
||||
@@ -1896,7 +1922,7 @@ export class FSService extends PuterService {
|
||||
input.uploadTracker,
|
||||
);
|
||||
|
||||
await this.stores.s3Object.uploadFromServer(
|
||||
await this.#uploadContent(
|
||||
{
|
||||
bucket: preparedItem.normalizedInput.bucket,
|
||||
objectKey: preparedItem.objectKey,
|
||||
@@ -1910,6 +1936,7 @@ export class FSService extends PuterService {
|
||||
: {}),
|
||||
},
|
||||
preparedItem.normalizedInput.bucketRegion,
|
||||
uploadBody,
|
||||
);
|
||||
|
||||
const uploadedSize = uploadBody.uploadedSize();
|
||||
@@ -3360,7 +3387,7 @@ export class FSService extends PuterService {
|
||||
uploadTracker,
|
||||
);
|
||||
const objectKey = existingEntry?.uuid ?? uuidv4();
|
||||
await this.stores.s3Object.uploadFromServer(
|
||||
await this.#uploadContent(
|
||||
{
|
||||
bucket: normalizedInput.bucket,
|
||||
objectKey,
|
||||
@@ -3374,6 +3401,7 @@ export class FSService extends PuterService {
|
||||
: {}),
|
||||
},
|
||||
normalizedInput.bucketRegion,
|
||||
uploadBody,
|
||||
);
|
||||
|
||||
const uploadedSize = uploadBody.uploadedSize();
|
||||
|
||||
@@ -1694,6 +1694,206 @@ describe('MeteringService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMonthAllowance / getMonthAllowance', () => {
|
||||
const paidPolicy = (id: string, dollars: number) => {
|
||||
const policy = {
|
||||
id,
|
||||
monthUsageAllowance: toMicroCents(dollars),
|
||||
monthlyStorageAllowance: 1024 * 1024 * 1024,
|
||||
};
|
||||
target.registerPolicy(policy);
|
||||
return policy;
|
||||
};
|
||||
|
||||
it('replaces the allowance for the rest of the month on that policy', async () => {
|
||||
const paid = paidPolicy('month-paid', 10);
|
||||
target.registerSubscriptionResolver(async () => 'month-paid');
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 1_000);
|
||||
// Cached before the override lands, so this also covers the drop.
|
||||
expect((await target.getAllowedUsage(actor)).remaining).toBe(
|
||||
paid.monthUsageAllowance - 1_000,
|
||||
);
|
||||
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
'month-paid',
|
||||
toMicroCents(4),
|
||||
);
|
||||
|
||||
const allowed = await target.getAllowedUsage(actor);
|
||||
expect(allowed.monthUsageAllowance).toBe(toMicroCents(4));
|
||||
expect(allowed.remaining).toBe(toMicroCents(4) - 1_000);
|
||||
expect(
|
||||
await target.getMonthAllowance(actor.user.uuid!, 'month-paid'),
|
||||
).toBe(toMicroCents(4));
|
||||
});
|
||||
|
||||
it('settles spend past the override against purchased credits', async () => {
|
||||
paidPolicy('month-paid', 10);
|
||||
target.registerSubscriptionResolver(async () => 'month-paid');
|
||||
await target.updateAddonCredit(actor.user.uuid!, 5_000_000);
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
'month-paid',
|
||||
3_000_000,
|
||||
);
|
||||
|
||||
await target.incrementUsage(actor, 'kv:read', 1, 4_000_000);
|
||||
|
||||
await waitFor(async () => {
|
||||
const addons = await target.getActorAddons(actor);
|
||||
expect(addons.consumedPurchaseCredits).toBe(1_000_000);
|
||||
});
|
||||
expect((await target.getAllowedUsage(actor)).remaining).toBe(
|
||||
4_000_000,
|
||||
);
|
||||
});
|
||||
|
||||
it('never applies on another policy', async () => {
|
||||
const basic = paidPolicy('month-basic', 5);
|
||||
paidPolicy('month-pro', 50);
|
||||
let tier = 'month-pro';
|
||||
target.registerSubscriptionResolver(async () => tier);
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
'month-pro',
|
||||
toMicroCents(20),
|
||||
);
|
||||
|
||||
// Leaving the plan drops the override with it.
|
||||
tier = 'month-basic';
|
||||
target.invalidateActorSubscription(actor.user.uuid!);
|
||||
expect(
|
||||
(await target.getActorSubscription(actor)).monthUsageAllowance,
|
||||
).toBe(basic.monthUsageAllowance);
|
||||
expect(
|
||||
await target.getMonthAllowance(actor.user.uuid!, 'month-basic'),
|
||||
).toBe(basic.monthUsageAllowance);
|
||||
});
|
||||
|
||||
it('lapses when the month ends', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
const paid = paidPolicy('month-paid', 10);
|
||||
target.registerSubscriptionResolver(async () => 'month-paid');
|
||||
vi.setSystemTime(SEPTEMBER_MONTH_ISO);
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
'month-paid',
|
||||
toMicroCents(4),
|
||||
);
|
||||
|
||||
vi.setSystemTime(OCTOBER_MONTH_ISO);
|
||||
target.invalidateActorSubscription(actor.user.uuid!);
|
||||
expect(
|
||||
(await target.getActorSubscription(actor))
|
||||
.monthUsageAllowance,
|
||||
).toBe(paid.monthUsageAllowance);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds an override for a later month until it is due to end', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
try {
|
||||
const paid = paidPolicy('month-paid', 10);
|
||||
target.registerSubscriptionResolver(async () => 'month-paid');
|
||||
vi.setSystemTime(SEPTEMBER_MONTH_ISO);
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
'month-paid',
|
||||
toMicroCents(4),
|
||||
{ month: '2026-10', until: Date.UTC(2026, 9, 5) / 1000 },
|
||||
);
|
||||
const allowanceNow = async () => {
|
||||
target.invalidateActorSubscription(actor.user.uuid!);
|
||||
return (await target.getActorSubscription(actor))
|
||||
.monthUsageAllowance;
|
||||
};
|
||||
|
||||
expect(await allowanceNow()).toBe(paid.monthUsageAllowance);
|
||||
vi.setSystemTime('2026-10-02T12:00:00Z');
|
||||
expect(await allowanceNow()).toBe(toMicroCents(4));
|
||||
expect(
|
||||
await target.getMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
'month-paid',
|
||||
'2026-10',
|
||||
),
|
||||
).toBe(toMicroCents(4));
|
||||
vi.setSystemTime('2026-10-06T12:00:00Z');
|
||||
expect(await allowanceNow()).toBe(paid.monthUsageAllowance);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves free policies alone without reading for an override', async () => {
|
||||
await target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
1,
|
||||
);
|
||||
const getSpy = vi.spyOn(server.stores.kv, 'get');
|
||||
|
||||
const policy = await target.getActorSubscription(actor);
|
||||
|
||||
expect(policy.id).toBe(DEFAULT_FREE_SUBSCRIPTION);
|
||||
expect(policy.monthUsageAllowance).toBeGreaterThan(1);
|
||||
const allowanceReads = getSpy.mock.calls.filter(([{ key }]) =>
|
||||
String(key).includes(':allowance:'),
|
||||
);
|
||||
expect(allowanceReads).toHaveLength(0);
|
||||
getSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back to the policy allowance when the override cannot be read', async () => {
|
||||
const paid = paidPolicy('month-paid', 10);
|
||||
target.registerSubscriptionResolver(async () => 'month-paid');
|
||||
const getSpy = vi
|
||||
.spyOn(server.stores.kv, 'get')
|
||||
.mockRejectedValueOnce(new Error('kv down'));
|
||||
|
||||
const policy = await target.getActorSubscription(actor);
|
||||
|
||||
expect(policy.monthUsageAllowance).toBe(paid.monthUsageAllowance);
|
||||
getSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('refuses an allowance that would read as unmetered', async () => {
|
||||
await expect(
|
||||
target.setMonthAllowance(actor.user.uuid!, 'month-paid', 0),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
target.setMonthAllowance(actor.user.uuid!, 'month-paid', 1, {
|
||||
month: 'October',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
// 0 would read as "never expires" to the store.
|
||||
await expect(
|
||||
target.setMonthAllowance(actor.user.uuid!, 'month-paid', 1, {
|
||||
until: 0,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 0.4]) {
|
||||
await expect(
|
||||
target.setMonthAllowance(
|
||||
actor.user.uuid!,
|
||||
'month-paid',
|
||||
bad,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('has no month allowance for a policy nobody registered', async () => {
|
||||
expect(
|
||||
await target.getMonthAllowance(actor.user.uuid!, 'ghost-plan'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── hasAnyUsageCached ────────────────────────────────────────────
|
||||
|
||||
describe('hasAnyUsageCached', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
DETAIL_PATH_COUNTER,
|
||||
FREE_SUBSCRIPTION_IDS,
|
||||
GLOBAL_APP_KEY,
|
||||
METRICS_PREFIX,
|
||||
METRICS_V2_PREFIX,
|
||||
@@ -2111,7 +2112,7 @@ export class MeteringService extends PuterService {
|
||||
availablePolicies.find((p) => p.id === resolvedUser) ??
|
||||
availablePolicies.find((p) => p.id === resolvedDefault) ??
|
||||
availablePolicies.find((p) => p.id === fallbackDefault);
|
||||
if (policy) return policy;
|
||||
if (policy) return this.#withMonthAllowance(actor.user.uuid!, policy);
|
||||
console.warn(
|
||||
`[metering] no registered policy for '${resolvedUser}' or` +
|
||||
` '${resolvedDefault}' — falling back to` +
|
||||
@@ -2120,6 +2121,67 @@ export class MeteringService extends PuterService {
|
||||
return REGISTERED_USER_FREE as SubscriptionPolicy;
|
||||
}
|
||||
|
||||
/**
|
||||
* `policy` with the month's allowance override applied, if one is set for
|
||||
* it (see `setMonthAllowance`). Free and unmetered policies never carry
|
||||
* one, so they skip the read.
|
||||
*/
|
||||
async #withMonthAllowance(
|
||||
userUuid: string,
|
||||
policy: SubscriptionPolicy,
|
||||
month: string = this.monthYearString(),
|
||||
): Promise<SubscriptionPolicy> {
|
||||
if (
|
||||
FREE_SUBSCRIPTION_IDS.has(policy.id) ||
|
||||
policy.id === UNLIMITED_SUBSCRIPTION ||
|
||||
!(policy.monthUsageAllowance > 0)
|
||||
) {
|
||||
return policy;
|
||||
}
|
||||
try {
|
||||
const allowance = await this.#readMonthAllowance(
|
||||
userUuid,
|
||||
policy.id,
|
||||
month,
|
||||
);
|
||||
return allowance === null
|
||||
? policy
|
||||
: ({
|
||||
...policy,
|
||||
monthUsageAllowance: allowance,
|
||||
} as SubscriptionPolicy);
|
||||
} catch (e) {
|
||||
// The policy's own allowance is what applied before any override.
|
||||
console.warn(
|
||||
`[metering] month allowance read failed for ${userUuid}: ${(e as Error).message}`,
|
||||
);
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
|
||||
#monthAllowanceKey(
|
||||
userUuid: string,
|
||||
policyId: string,
|
||||
month: string,
|
||||
): string {
|
||||
return `${POLICY_PREFIX}:actor:${userUuid}:allowance:${policyId}:${month}`;
|
||||
}
|
||||
|
||||
async #readMonthAllowance(
|
||||
userUuid: string,
|
||||
policyId: string,
|
||||
month: string,
|
||||
): Promise<number | null> {
|
||||
const { res } = await this.stores.kv.get({
|
||||
key: this.#monthAllowanceKey(userUuid, policyId, month),
|
||||
});
|
||||
// Non-positive reads as unmetered everywhere downstream, so it is
|
||||
// never applied.
|
||||
return typeof res === 'number' && Number.isFinite(res) && res > 0
|
||||
? res
|
||||
: null;
|
||||
}
|
||||
|
||||
async getActorAddons(actor: Actor): Promise<UsageAddons> {
|
||||
if (!actor.user?.uuid)
|
||||
throw new HttpError(
|
||||
@@ -2208,6 +2270,61 @@ export class MeteringService extends PuterService {
|
||||
this.invalidateActorCredits(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one actor's allowance for a month (`YYYY-MM` UTC, the current one
|
||||
* by default) while they resolve to `policyId`, until `until` (epoch
|
||||
* seconds) if given. It never applies on any other policy, so leaving the
|
||||
* plan drops it. For a change that should count for only part of a month,
|
||||
* such as a plan change billed for part of a period.
|
||||
*/
|
||||
async setMonthAllowance(
|
||||
userUuid: string,
|
||||
policyId: string,
|
||||
allowance: number,
|
||||
opts: { month?: string; until?: number } = {},
|
||||
): Promise<void> {
|
||||
if (!userUuid || !policyId) {
|
||||
throw new Error('User and policy needed to set a month allowance');
|
||||
}
|
||||
const value = Math.round(allowance);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error('Month allowance must be a positive number');
|
||||
}
|
||||
const month = opts.month ?? this.monthYearString();
|
||||
const parsed = /^(\d{4})-(\d{2})$/.exec(month);
|
||||
if (!parsed) throw new Error('Month must be YYYY-MM');
|
||||
// Nothing reads a month's key once the month is over.
|
||||
const monthOver =
|
||||
Date.UTC(Number(parsed[1]), Number(parsed[2]), 1) / 1000;
|
||||
const until = opts.until;
|
||||
if (until !== undefined && !(Number.isFinite(until) && until > 0)) {
|
||||
throw new Error('`until` must be a timestamp in seconds');
|
||||
}
|
||||
await this.stores.kv.set({
|
||||
key: this.#monthAllowanceKey(userUuid, policyId, month),
|
||||
value,
|
||||
expireAt: Math.min(until ?? monthOver, monthOver),
|
||||
});
|
||||
// Every node drops its cached policy, and the budget answer with it.
|
||||
this.invalidateActorSubscription(userUuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* A month's allowance for `policyId` (`YYYY-MM` UTC, the current one by
|
||||
* default): the override `setMonthAllowance` left, else the policy's own.
|
||||
* Null for a policy nobody registered.
|
||||
*/
|
||||
async getMonthAllowance(
|
||||
userUuid: string,
|
||||
policyId: string,
|
||||
month?: string,
|
||||
): Promise<number | null> {
|
||||
const policy = this.getRegisteredPolicy(policyId);
|
||||
if (!policy) return null;
|
||||
return (await this.#withMonthAllowance(userUuid, policy, month))
|
||||
.monthUsageAllowance;
|
||||
}
|
||||
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
private monthYearString(): string {
|
||||
|
||||
@@ -27,12 +27,11 @@ export type AppDataStore = 'kv' | 'fs';
|
||||
* Access classes a grant may be written at. Coarser than a concrete op, so a
|
||||
* single `…:kv:read` row covers `get` and `list`.
|
||||
*
|
||||
* `delete` is orthogonal: `write` does not imply it and it does not imply
|
||||
* `write`. That is what lets a grant say "may add invites but not remove them",
|
||||
* and conversely "may cancel an invite" without handing over the ability to
|
||||
* rewrite everything. Coarser grants (`app-data:X:kv`, `app-data:X`) still
|
||||
* cover all three by prefix implication, which is why the consent dialog has to
|
||||
* name deletion whenever it prompts for one.
|
||||
* Each class implies the ones below it: `delete` > `write` > `read`. An app
|
||||
* can't remove an entry it may not see or change, so `delete` carries both;
|
||||
* `write` still never reaches a deletion. Coarser grants (`app-data:X:kv`,
|
||||
* `app-data:X`) cover all three by prefix implication, which is why the consent
|
||||
* dialog has to name deletion whenever it prompts for one.
|
||||
*/
|
||||
export const APP_DATA_CLASSES = ['read', 'write', 'delete'] as const;
|
||||
export type AppDataClass = (typeof APP_DATA_CLASSES)[number];
|
||||
@@ -57,7 +56,7 @@ export type AppDataKvOp = (typeof APP_DATA_KV_OPS)[number];
|
||||
* Parameters that turn a write into a deletion: both set an expiry, and an
|
||||
* expiry in the past makes the key vanish (the store filters it out on read and
|
||||
* DynamoDB reaps it later). A cross-app call carrying either one therefore
|
||||
* needs the `delete` class on top of `write` — otherwise `kv:set` alone would
|
||||
* needs the `delete` class, not just `write` — otherwise `kv:set` alone would
|
||||
* be a delete capability under another name.
|
||||
*/
|
||||
export const APP_DATA_KV_TTL_PARAMS = ['expireAt', 'ttl'] as const;
|
||||
@@ -67,13 +66,13 @@ export const APP_DATA_KV_OP_CLASSES: Record<
|
||||
AppDataKvOp,
|
||||
readonly AppDataClass[]
|
||||
> = {
|
||||
get: ['read', 'write'],
|
||||
list: ['read', 'write'],
|
||||
set: ['write'],
|
||||
add: ['write'],
|
||||
incr: ['write'],
|
||||
decr: ['write'],
|
||||
update: ['write'],
|
||||
get: ['read', 'write', 'delete'],
|
||||
list: ['read', 'write', 'delete'],
|
||||
set: ['write', 'delete'],
|
||||
add: ['write', 'delete'],
|
||||
incr: ['write', 'delete'],
|
||||
decr: ['write', 'delete'],
|
||||
update: ['write', 'delete'],
|
||||
del: ['delete'],
|
||||
remove: ['delete'],
|
||||
expire: ['delete'],
|
||||
|
||||
@@ -29,6 +29,7 @@ import { setupTestServer } from '../../testUtil.js';
|
||||
import type { IConfig } from '../../types.js';
|
||||
import {
|
||||
clampSignedUploadExpirySeconds,
|
||||
isIncompleteBodyError,
|
||||
isMissingObjectError,
|
||||
S3ObjectStore,
|
||||
} from './S3ObjectStore.js';
|
||||
@@ -184,6 +185,21 @@ describe('isMissingObjectError', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIncompleteBodyError', () => {
|
||||
it('reads a short upload body off name or Code', () => {
|
||||
expect(isIncompleteBodyError({ name: 'IncompleteBody' })).toBe(true);
|
||||
expect(isIncompleteBodyError({ Code: 'IncompleteBody' })).toBe(true);
|
||||
});
|
||||
|
||||
it('never reads other upload failures as a short body', () => {
|
||||
expect(isIncompleteBodyError({ name: 'SlowDown' })).toBe(false);
|
||||
expect(isIncompleteBodyError({ name: 'BadDigest' })).toBe(false);
|
||||
expect(isIncompleteBodyError(new Error('socket hang up'))).toBe(false);
|
||||
expect(isIncompleteBodyError(null)).toBe(false);
|
||||
expect(isIncompleteBodyError('IncompleteBody')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3ObjectStore region and bucket fallbacks', () => {
|
||||
it('falls back to configured region then to the built-in default', () => {
|
||||
expect(store().resolveRegion('eu-central-1')).toBe('eu-central-1');
|
||||
|
||||
@@ -73,6 +73,16 @@ export function isMissingObjectError(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the store rejected an upload because the body ended before the
|
||||
* length it was sent with.
|
||||
*/
|
||||
export function isIncompleteBodyError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const e = err as { name?: unknown; Code?: unknown };
|
||||
return e.name === 'IncompleteBody' || e.Code === 'IncompleteBody';
|
||||
}
|
||||
|
||||
/**
|
||||
* Store that owns S3 object I/O for fsentries: signed-URL minting, multipart
|
||||
* lifecycle, server-driven uploads, and object reads/copies/deletes. Wraps the
|
||||
|
||||
@@ -41,10 +41,10 @@ What access to ask for. Three equivalent forms:
|
||||
| Class | Covers |
|
||||
| --- | --- |
|
||||
| `read` | `get`, `list` |
|
||||
| `write` | `set`, `add`, `incr`, `decr`, `update` |
|
||||
| `delete` | `del`, `remove`, `expire`, `expireAt` |
|
||||
| `write` | everything in `read`, plus `set`, `add`, `incr`, `decr`, `update` |
|
||||
| `delete` | everything in `write`, plus `del`, `remove`, `expire`, `expireAt` |
|
||||
|
||||
**`delete` is separate from `write`.** An app granted `write` can add and change entries but cannot remove any — ask for `delete` explicitly when it needs to. Emptying another app's whole key-value store is never available at any scope.
|
||||
**Each class includes the ones above it.** An app granted `write` can read, add and change entries but cannot remove any — ask for `delete` when it needs to. Emptying another app's whole key-value store is never available at any scope.
|
||||
|
||||
## Return value
|
||||
|
||||
|
||||
@@ -140,7 +140,9 @@ Per minute unless stated:
|
||||
| Limit | Value | Scope |
|
||||
| ----- | ----- | ----- |
|
||||
| Uploads in progress | 10,000 | Per user, all apps |
|
||||
| Files in one `startBatchWrite` | 10,000 | Per request |
|
||||
| Files and folders in one `startBatchWrite`, `completeBatchWrite` or `batchWrite` | 500 | Per request |
|
||||
| Entries in one `/sign` request | 500 | Per request |
|
||||
| Part numbers in one `signMultipartParts` | 10,000 | Per request |
|
||||
| Legacy `/batch` request | 256 multipart parts (fields and files combined) or 256 JSON operations, 64 files, 100 MiB per file, 1 MiB per field, 256 MiB in total | Per request |
|
||||
| Signed-URL reads | 3,000/min | Per network |
|
||||
| Signed-URL writes | 600/min | Per network |
|
||||
@@ -150,6 +152,8 @@ Per minute unless stated:
|
||||
|
||||
Non-write operations (mkdir/shortcut/move/delete) inside a legacy `/batch` request also count against the Mutations limits above.
|
||||
|
||||
A request over one of the per-request caps fails as a whole before any item is processed, with `400 bad_request` (`413` for legacy `/batch`). `puter.fs.upload()` already splits larger uploads into requests of 500.
|
||||
|
||||
An upload is in progress from `startWrite`/`startBatchWrite` (which `puter.fs.upload()` and `puter.fs.write()` use) until it completes, is cancelled, or expires. The count includes uploads by collaborators into the account's folders. Over the limit, new uploads fail with `429 too_many_requests` until some finish.
|
||||
|
||||
The Puter desktop makes PDF upload thumbnails locally within these budgets. Going over one skips the preview; the upload itself is unaffected.
|
||||
|
||||
@@ -103,3 +103,75 @@ The request is a plain `fetch()`, so visitors do not need a Puter account to
|
||||
use the form.
|
||||
|
||||
Every message sent through the form now arrives in your email inbox.
|
||||
|
||||
## Limit How Often a Visitor Can Send
|
||||
|
||||
Anyone who knows the worker's URL can call the `/contact` route, and every call
|
||||
sends an email billed to you. To keep a bot from flooding your inbox, add a
|
||||
rate limit. Record the visitor's IP address in [`puter.kv`](/KV/) with an
|
||||
expiry time, and while the key exists, the route answers `429` and sends
|
||||
nothing.
|
||||
|
||||
Here is the complete worker file with a rate limit of one message every 3
|
||||
minutes. Change `COOLDOWN_SECONDS` to fit your app:
|
||||
|
||||
```js
|
||||
const CONTACT_TO = 'you@example.com';
|
||||
const COOLDOWN_SECONDS = 3 * 60;
|
||||
|
||||
function toPrefix48(addr) {
|
||||
if (!addr.includes(':')) return addr; // IPv4: leave unchanged
|
||||
|
||||
const [head, tail = ''] = addr.split('%')[0].split('::'); // drop zone ID
|
||||
const left = head ? head.split(':') : [];
|
||||
const right = tail ? tail.split(':') : [];
|
||||
const full = [...left, ...Array(8 - left.length - right.length).fill('0'), ...right];
|
||||
|
||||
const parts = full.slice(0, 3).map(h => parseInt(h || '0', 16).toString(16));
|
||||
while (parts.length && parts[parts.length - 1] === '0') parts.pop();
|
||||
|
||||
return `${parts.join(':')}::/48`;
|
||||
}
|
||||
|
||||
async function isRateLimited (request) {
|
||||
const key = `contact:${toPrefix48(request.headers.get('x-real-ip'))}`;
|
||||
|
||||
if ( await me.puter.kv.get(key) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const expireAt = Math.floor(Date.now() / 1000) + COOLDOWN_SECONDS;
|
||||
await me.puter.kv.set(key, true, expireAt);
|
||||
return false;
|
||||
}
|
||||
|
||||
router.post('/contact', async ({ request }) => {
|
||||
const { name, email, message } = await request.json();
|
||||
if (!name || !email || !message) {
|
||||
return new Response('name, email and message are required', { status: 400 });
|
||||
}
|
||||
|
||||
if ( await isRateLimited(request) ) {
|
||||
return new Response('Please wait a few minutes before sending another message', { status: 429 });
|
||||
}
|
||||
|
||||
await me.puter.email.sendTransactional({
|
||||
to: CONTACT_TO,
|
||||
replyTo: email,
|
||||
subject: `Contact form: ${name}`,
|
||||
text: `From: ${name} <${email}>\n\n${message}`,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
```
|
||||
|
||||
The `x-real-ip` header carries the visitor's IP address, and it cannot be faked to get around
|
||||
the limit. The `toPrefix48()` helper handles both IPv4 and IPv6. An IPv4 address is used
|
||||
as is. One IPv6 connection usually comes with a whole block of addresses, so an
|
||||
IPv6 address is rate limited by its /48 prefix instead of the single address. The [`puter.kv.set()`](/KV/set/) method takes the expiry as a Unix
|
||||
timestamp in seconds, and once it passes, the key is removed and the visitor
|
||||
can send again.
|
||||
|
||||
The limit is checked after the fields are validated, so a form with a missing
|
||||
field does not start the cooldown.
|
||||
@@ -710,13 +710,13 @@ async function get_permission_description (permission, options = {}) {
|
||||
* explicitly: a scope that can remove another app's entries must not read as
|
||||
* "change".
|
||||
*/
|
||||
// `write` satisfies a read check via the exploder, so its wording names
|
||||
// reading too — "change" alone would understate the grant.
|
||||
// Classes imply the ones below them (delete > write > read), so their wording
|
||||
// names those too: "change" or "delete" alone would understate the grant.
|
||||
const APP_DATA_VERBS = {
|
||||
read: 'read', get: 'read', list: 'read',
|
||||
write: 'change', set: 'change', add: 'change',
|
||||
incr: 'change', decr: 'change', update: 'change',
|
||||
delete: 'delete', del: 'delete', remove: 'delete',
|
||||
delete: 'store_all', del: 'delete', remove: 'delete',
|
||||
expire: 'delete', expireAt: 'delete',
|
||||
};
|
||||
|
||||
|
||||
@@ -52,8 +52,13 @@ describe('UIPermissionDialog app-data descriptions', () => {
|
||||
// Deletion must be named, not folded into "change".
|
||||
expect((await describeScope(`app-data:${CONTACTS}:kv:del`)).html)
|
||||
.toContain('perm_app_data_delete');
|
||||
});
|
||||
|
||||
it('names reading and changing for the delete class, which implies both', async () => {
|
||||
expect((await describeScope(`app-data:${CONTACTS}:kv:delete`)).html)
|
||||
.toContain('perm_app_data_delete');
|
||||
.toContain('perm_app_data_store_all');
|
||||
expect((await describeScope(`app-data:${CONTACTS}:fs:delete`)).html)
|
||||
.toContain('perm_app_data_store_all');
|
||||
});
|
||||
|
||||
it('distinguishes files from saved data', async () => {
|
||||
|
||||
@@ -170,7 +170,7 @@ export async function appDataRequest (puter, appIdentifier, scopes) {
|
||||
/**
|
||||
* Ask the user to let this app use another app's KV namespace and AppData
|
||||
* directory. Scopes take a shorthand for both stores, `store:name` pairs, or a
|
||||
* per-store object. `delete` is separate from `write` and must be asked for.
|
||||
* per-store object. `delete` includes `write` and `read`, and must be asked for.
|
||||
*
|
||||
* await puter.perms.request('appData', { app: 'contacts', scopes: 'read' });
|
||||
*
|
||||
|
||||
@@ -109,8 +109,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* The three access classes. `delete` is orthogonal to `write`: neither implies
|
||||
* the other, so an app that only adds data cannot remove any.
|
||||
* The three access classes. Each implies the ones before it (`delete` >
|
||||
* `write` > `read`), so an app that only adds data cannot remove any.
|
||||
*
|
||||
* @typedef {'read' | 'write' | 'delete'} AppDataClass
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user