mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-20 10:32:36 +00:00
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Pipeline - CORS preflights send Access-Control-Max-Age: browsers stop re-preflighting authed calls every 5 s. - `req.ua` is parsed on first read instead of on every request. - JSON `rawBody` reuses the parsed buffer instead of copying it. - `emitAndWait` returns early when nothing listens: no span or closure per unlistened driver call. - SQLBatcher sends a lone query on the next tick while fewer than 8 flushes are in flight, and waits `maxTimeInQueue` as before otherwise. Lone reads skip the 10/30 ms wait; batching under load is unchanged. Auth and permissions - Token-to-actor auth starts the user, session and app lookups together and checks them in the same order: fewer sequential Redis round trips per authed request. - get-user-app-token and check-app reuse the resolved app row. getOrCreateApp skips the re-select after its own insert and doesn't wait on the cache write for replica rows. - getOrCreateApp reads the race winner from the primary when its insert loses, so it no longer hands out a session that was never written. - whoami runs the email check and the OIDC/taskbar/team lookups in parallel; key order unchanged. - OIDC providers are memoized once every discovery succeeds, with one discovery fetch in flight per URL, and the Apple key is parsed once. - gui-token and sync-cookie use `req.actor.user` instead of re-reading the user. - listSessions batches app lookups. App icon probes run together, and the original-icon probe skips the lineage fallback. - Default-granted permissions are answered from config before the scan cache, and exploded options are deduped: one less Redis read per /drivers/call. - ACL resolves ancestors lazily (owners and full-access tokens skip them) and scans the node first, then the ancestors concurrently on a miss. Filesystem - Missing-path lineage lookups run 10 at a time: batch-upload path resolution drops from about 1.3 s to about 40 ms per 100 new files. - MySQL single-file inserts return the row built from the written values, falling back to the primary read: one less primary round trip per new file. - Batch thumbnails are stored with one UPDATE per chunk (200 rows or 4M chars), with a per-row fallback. - storage.quota.bonus listeners run concurrently next to the usage SUM, and the payload carries `userUuid`: quota checks and /df no longer serialize the listener reads. - readdir cache backfill is one pipeline instead of 3xN SETEX. - Profile reads skip the lineage fallback; the own profile is read alongside the user lookup. - Legacy /stat runs is_empty (new `hasChildrenByParentUid`), owner and thumbnail concurrently. - Downloads and the video proxy pipe through `pipeToResponse`: aborts release the storage stream at once with no extra 'close' listeners, and the video proxy no longer crashes on a mid-stream upstream error. - mkdir looks up target and parent together. - Upload completion reuses the session from the access check (status is still re-read before completing), and multipart signing reads it once. - Moving a shared file to Trash skips two lookups that always returned the entry itself. Drivers and events - Subscription resolvers run concurrently and are folded in registration order. - SubdomainDriver and WorkerDriver overlap independent lookups. - The events "anyone subscribed?" check is one Redis pipeline when the region is warm. - Handler publish overlaps the count with the size check. - Broadcast flushes peers concurrently over a keep-alive agent, so a slow peer no longer delays cross-region invalidations to the others. Sites, share and AI - Hosted sites resolve the owner first, then the app and root together. Site config loads only on 404s. The hosted-app query selects only the columns it uses and moves into AppStore. - shareFlags asks about the plan only when an item is shared by link only. - Share listings run the count, teams and plan reads alongside their page query. - Claude attachment cleanup no longer blocks the response. - New util `awaitLater` for promises started early and awaited later.
160 lines
4.9 KiB
TypeScript
160 lines
4.9 KiB
TypeScript
import { Context } from '@heyputer/backend/src/core';
|
|
import { extension } from '@heyputer/backend/src/extensions';
|
|
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
|
|
import {
|
|
buildUserDetails,
|
|
scrubSensitive,
|
|
} from '@heyputer/backend/src/util/userDetails.js';
|
|
import type { Request, Response } from 'express';
|
|
import TimeAgo from 'javascript-time-ago';
|
|
import localeEn from 'javascript-time-ago/locale/en';
|
|
|
|
const stores = extension.import('store');
|
|
const services = extension.import('service');
|
|
const clients = extension.import('client');
|
|
|
|
const timeago = (() => {
|
|
TimeAgo.addDefaultLocale(localeEn);
|
|
return new TimeAgo('en-US');
|
|
})();
|
|
|
|
// What an app actor may receive from `whoami.details` listeners. Anything
|
|
// else a listener adds describes the account, and only ships to user actors.
|
|
const APP_VISIBLE_LISTENER_KEYS: ReadonlySet<string> = new Set([
|
|
'subscribed',
|
|
'paid_storage',
|
|
]);
|
|
|
|
export const handleWhoami = async (
|
|
req: Request,
|
|
res: Response,
|
|
): Promise<void> => {
|
|
const actor = Context.get('actor');
|
|
if (!actor?.user?.id) {
|
|
res.status(401).json({ error: 'Authentication required' });
|
|
return;
|
|
}
|
|
|
|
const isUser = !actor.effectiveApp;
|
|
const canReadEmail = async (uuid: unknown) => {
|
|
try {
|
|
return await services.permission.check(
|
|
actor,
|
|
`user:${uuid}:email:read`,
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
// Started beside the row read: the actor carries the row's uuid.
|
|
const emailCheck = isUser ? null : canReadEmail(actor.user.uuid);
|
|
const user = await stores.user.getById(actor.user.id);
|
|
if (!user) {
|
|
res.status(404).json({ error: 'User not found' });
|
|
return;
|
|
}
|
|
|
|
const rawIconSize =
|
|
typeof req.query?.icon_size === 'string'
|
|
? Number(req.query.icon_size)
|
|
: undefined;
|
|
const iconSize =
|
|
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
|
|
? rawIconSize
|
|
: undefined;
|
|
|
|
const details = await buildUserDetails(
|
|
user,
|
|
{ config: extension.config, clients, stores, services },
|
|
{ isUser, iconSize, noIcons: !iconSize },
|
|
);
|
|
details.human_readable_age = user.timestamp
|
|
? timeago.format(new Date(user.timestamp as string))
|
|
: null;
|
|
|
|
// Strip sensitive fields for app actors
|
|
if (!isUser) {
|
|
const emailReadable = await (user.uuid === actor.user.uuid
|
|
? emailCheck
|
|
: canReadEmail(user.uuid));
|
|
if (!emailReadable) {
|
|
delete details.email;
|
|
delete details.unconfirmed_email;
|
|
}
|
|
delete details.desktop_bg_url;
|
|
delete details.desktop_bg_color;
|
|
delete details.desktop_bg_fit;
|
|
delete details.human_readable_age;
|
|
delete details.created_ts;
|
|
delete details.is_user_token;
|
|
delete details.metadata;
|
|
// An app has no business reading the code its user earns credit with.
|
|
delete details.referral_code;
|
|
}
|
|
|
|
const app = actor.effectiveApp;
|
|
if (app) {
|
|
details.app_name = app.uid;
|
|
}
|
|
|
|
// A key core left undefined isn't built, so a listener can't fill it for an app.
|
|
const builtKeys = isUser
|
|
? null
|
|
: new Set(
|
|
Object.keys(details).filter((key) => details[key] !== undefined),
|
|
);
|
|
try {
|
|
await clients.event.emitAndWait(
|
|
'whoami.details',
|
|
{ user, details, isUser },
|
|
{},
|
|
);
|
|
} catch {
|
|
/* best-effort */
|
|
}
|
|
if (builtKeys) {
|
|
for (const key of Object.keys(details)) {
|
|
if (!builtKeys.has(key) && !APP_VISIBLE_LISTENER_KEYS.has(key)) {
|
|
delete details[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
const subscription = details.subscription as
|
|
| { offering?: Record<string, unknown> }
|
|
| undefined;
|
|
if (subscription?.offering) {
|
|
delete subscription.offering.group;
|
|
delete subscription.offering.benefits;
|
|
delete subscription.offering.price_id;
|
|
}
|
|
|
|
// Last word on what ships, after every listener has had its say.
|
|
scrubSensitive(details);
|
|
|
|
res.json(details);
|
|
};
|
|
|
|
extension.get(
|
|
'/whoami',
|
|
{
|
|
subdomain: 'api',
|
|
requireAuth: true,
|
|
allowUnconfirmed: true,
|
|
// The GUI polls this, and each call fans out to every `whoami`
|
|
// event listener — so it costs more than the response suggests.
|
|
//
|
|
// It is also the call everything else leans on to find out who it is
|
|
// talking to, so it rides along with unrelated work rather than
|
|
// arriving at its own pace: the ceiling has to clear whatever the
|
|
// busiest session is doing, not what a person clicks.
|
|
rateLimit: {
|
|
scope: 'whoami',
|
|
limit: 1_800,
|
|
window: 60_000,
|
|
key: 'user',
|
|
},
|
|
},
|
|
handleWhoami,
|
|
);
|