mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 14:07:51 +00:00
Renaming or moving a directory rewrote its descendants' paths but left their
cached copies (by id, uuid and old path) live for up to 60 s. A lookup of an
old path kept resolving, and a write beneath one landed at a path its parent
no longer had. updatePathPrefixForUser and renameUserHome now drop those keys,
here and, within the broadcast budget, in peer regions.
puter.js sent `cursor=null` for `listShared`/`listSharedByMe({ cursor: null })`,
which decoded to null and failed every keyset listing with a 500. decodeCursor
reads it as no cursor, and the SDK no longer sends it.
245 lines
8.1 KiB
TypeScript
245 lines
8.1 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {
|
|
createCipheriv,
|
|
createDecipheriv,
|
|
hkdfSync,
|
|
randomBytes,
|
|
} from 'node:crypto';
|
|
import { HttpError } from '../core/http';
|
|
|
|
/**
|
|
* Shared pagination envelope for list endpoints. A response carries `cursor`
|
|
* only when more pages exist; `total` only when the request asked for it via
|
|
* `includeTotal`. Pages may hold fewer than `limit` items (post-query
|
|
* filtering) — clients iterate until `cursor` is absent.
|
|
*/
|
|
export interface PageResult<T> {
|
|
items: T[];
|
|
cursor?: string;
|
|
total?: number;
|
|
}
|
|
|
|
export const encodeCursor = (
|
|
payload?: Record<string, unknown>,
|
|
): string | undefined => {
|
|
if (!payload || Object.keys(payload).length === 0) return undefined;
|
|
return Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
};
|
|
|
|
export const decodeCursor = (
|
|
cursor?: string | Record<string, unknown> | null,
|
|
label = 'cursor',
|
|
): Record<string, unknown> | undefined => {
|
|
if (cursor === undefined || cursor === null) return undefined;
|
|
if (typeof cursor === 'object') return cursor;
|
|
const trimmed = cursor.trim();
|
|
if (trimmed === '') return undefined;
|
|
let parsed: Record<string, unknown> | null;
|
|
try {
|
|
parsed = JSON.parse(Buffer.from(trimmed, 'base64').toString('utf8'));
|
|
} catch {
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
throw new HttpError(400, `invalid ${label}`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
}
|
|
// A client that stringifies a null cursor sends `cursor=null`.
|
|
return parsed ?? undefined;
|
|
};
|
|
|
|
// -- Sealed cursors ---------------------------------------------------
|
|
|
|
const SEALED_CURSOR_VERSION = 1;
|
|
const SEAL_IV_BYTES = 12;
|
|
const SEAL_TAG_BYTES = 16;
|
|
const SEAL_HEADER_BYTES = 1 + SEAL_IV_BYTES + SEAL_TAG_BYTES;
|
|
|
|
let sealKeyMemo: { secret: string; key: Buffer } | null = null;
|
|
|
|
/** Derived under its own label, so no other use of `secret` shares this key. */
|
|
const sealKey = (secret: string): Buffer => {
|
|
if (sealKeyMemo?.secret !== secret)
|
|
sealKeyMemo = {
|
|
secret,
|
|
key: Buffer.from(
|
|
hkdfSync('sha256', secret, '', 'puter:pagination-cursor', 32),
|
|
),
|
|
};
|
|
return sealKeyMemo.key;
|
|
};
|
|
|
|
/**
|
|
* A cursor its holder cannot read, for keysets over a global sequence: a plain
|
|
* one would let two pages show how fast the whole table grows. Encrypted and
|
|
* authenticated, so an altered cursor is refused like any malformed one.
|
|
* Without a secret it is a plain `encodeCursor` cursor.
|
|
*/
|
|
export const sealCursor = (
|
|
payload: Record<string, unknown> | undefined,
|
|
secret: string | undefined,
|
|
): string | undefined => {
|
|
if (!payload || Object.keys(payload).length === 0) return undefined;
|
|
if (!secret) return encodeCursor(payload);
|
|
const iv = randomBytes(SEAL_IV_BYTES);
|
|
const cipher = createCipheriv('aes-256-gcm', sealKey(secret), iv);
|
|
const body = Buffer.concat([
|
|
cipher.update(JSON.stringify(payload), 'utf8'),
|
|
cipher.final(),
|
|
]);
|
|
return Buffer.concat([
|
|
Buffer.from([SEALED_CURSOR_VERSION]),
|
|
iv,
|
|
cipher.getAuthTag(),
|
|
body,
|
|
]).toString('base64url');
|
|
};
|
|
|
|
const unsealCursor = (
|
|
cursor: string,
|
|
secret: string,
|
|
): Record<string, unknown> | null => {
|
|
const raw = Buffer.from(cursor, 'base64url');
|
|
if (raw.length <= SEAL_HEADER_BYTES || raw[0] !== SEALED_CURSOR_VERSION)
|
|
return null;
|
|
try {
|
|
const decipher = createDecipheriv(
|
|
'aes-256-gcm',
|
|
sealKey(secret),
|
|
raw.subarray(1, 1 + SEAL_IV_BYTES),
|
|
);
|
|
decipher.setAuthTag(raw.subarray(1 + SEAL_IV_BYTES, SEAL_HEADER_BYTES));
|
|
const parsed: unknown = JSON.parse(
|
|
Buffer.concat([
|
|
decipher.update(raw.subarray(SEAL_HEADER_BYTES)),
|
|
decipher.final(),
|
|
]).toString('utf8'),
|
|
);
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
? (parsed as Record<string, unknown>)
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Read a cursor `sealCursor` produced. A plain cursor still reads, so a client
|
|
* holding one from before cursors were sealed does not start over.
|
|
*/
|
|
export const openCursor = (
|
|
cursor: string | Record<string, unknown> | null | undefined,
|
|
secret: string | undefined,
|
|
label = 'cursor',
|
|
): Record<string, unknown> | undefined => {
|
|
if (typeof cursor === 'string' && secret) {
|
|
const opened = unsealCursor(cursor.trim(), secret);
|
|
if (opened) return opened;
|
|
}
|
|
return decodeCursor(cursor, label);
|
|
};
|
|
|
|
export const normalizeLimit = (
|
|
limit: unknown,
|
|
{ cap, label = 'limit' }: { cap?: number; label?: string } = {},
|
|
): number | undefined => {
|
|
if (limit === undefined || limit === null) return undefined;
|
|
const parsed = Number(limit);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
throw new HttpError(400, `${label} must be a positive number`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
const floored = Math.floor(parsed);
|
|
return cap !== undefined ? Math.min(floored, cap) : floored;
|
|
};
|
|
|
|
export const normalizeOffset = (
|
|
offset: unknown,
|
|
{ cap, label = 'offset' }: { cap?: number; label?: string } = {},
|
|
): number | undefined => {
|
|
if (offset === undefined || offset === null) return undefined;
|
|
const parsed = Number(offset);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
throw new HttpError(400, `${label} must be a non-negative number`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
const floored = Math.floor(parsed);
|
|
if (cap !== undefined && floored > cap) {
|
|
throw new HttpError(400, `${label} may not exceed ${cap}`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
return floored;
|
|
};
|
|
|
|
// -- Keyset pages -----------------------------------------------------
|
|
|
|
/**
|
|
* The id a keyset page resumes from, or null on the first page. A cursor that
|
|
* opens without a usable id (another listing's, say) throws when `strict`, and
|
|
* otherwise reads as the first page.
|
|
*/
|
|
export const openIdCursor = (
|
|
cursor: string | Record<string, unknown> | null | undefined,
|
|
secret: string | undefined,
|
|
{
|
|
label = 'cursor',
|
|
strict = false,
|
|
}: { label?: string; strict?: boolean } = {},
|
|
): number | null => {
|
|
const decoded = openCursor(cursor, secret, label);
|
|
if (decoded === undefined) return null;
|
|
const id = Number(decoded.id);
|
|
if (Number.isSafeInteger(id) && id >= 0) return id;
|
|
if (strict) {
|
|
throw new HttpError(400, `invalid ${label}`, {
|
|
legacyCode: 'bad_request',
|
|
});
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* One page of `rows`, fetched with `LIMIT limit + 1`: the first `limit` rows,
|
|
* and a cursor at the last of them when the extra row says another page
|
|
* follows. `position` is the cursor payload; it defaults to the row's `id`.
|
|
* Without `secret` the cursor is a plain `encodeCursor` one.
|
|
*/
|
|
export const keysetPage = <R>(
|
|
rows: R[],
|
|
limit: number,
|
|
secret: string | undefined,
|
|
position: (last: R) => Record<string, unknown> = (last) => ({
|
|
id: Number((last as { id?: unknown }).id),
|
|
}),
|
|
): { rows: R[]; cursor?: string } => {
|
|
if (rows.length <= limit) return { rows };
|
|
const page = rows.slice(0, limit);
|
|
return {
|
|
rows: page,
|
|
cursor: sealCursor(position(page[page.length - 1]!), secret),
|
|
};
|
|
};
|