fix: misc bugs (#4251)
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
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s

This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-15 23:49:17 -07:00
1 parent 2a15afc570
commit 2b254b8892
14 files changed
+223 -120

No files matched your search

@@ -1523,6 +1523,86 @@ describe('createPuterSiteMiddleware — __workers/ is never served', () => {
expect(Buffer.isBuffer(piped)).toBe(true);
expect(piped!.equals(shell)).toBe(true);
});
// SQLite compares paths bytewise; MySQL's utf8mb4_unicode_ci folds
// accents, fullwidth forms and zero-width chars. Emulate that fold.
const withCollationAlias = async (
alias: string,
real: string,
fn: () => Promise<void>,
) => {
const store = server.stores.fsEntry;
const original = store.getEntryByPath.bind(store);
const spy = vi
.spyOn(store, 'getEntryByPath')
.mockImplementation((path, options) =>
original(
path.startsWith(alias)
? real + path.slice(alias.length)
: path,
options,
),
);
try {
await fn();
} finally {
spy.mockRestore();
}
};
it('404s a collation alias of __workers/ that the database resolves to the real file', async () => {
const { homePath, sub } = await setupSiteWithWorker();
await withCollationAlias(
`${homePath}/__wörkers/matchmaking.worker.js`,
`${homePath}/__workers/matchmaking.worker.js`,
async () => {
const out = await request(
sub,
'/__w%C3%B6rkers/matchmaking.worker.js',
);
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('secret');
},
);
});
it('404s a collation alias of a folder whose index.html sits in __workers/', async () => {
const { owner, homePath, sub } = await setupSiteWithWorker();
await writeFile(
owner.id,
`${homePath}/__workers/index.html`,
Buffer.from('<html>listing</html>'),
'text/html',
);
await withCollationAlias(
`${homePath}/__wörkers`,
`${homePath}/__workers`,
async () => {
const out = await request(sub, '/__w%C3%B6rkers');
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('listing');
},
);
});
it('404s a collation alias of .puter_site_config', async () => {
const { owner, homePath, sub } = await setupSiteWithWorker();
await writeFile(
owner.id,
`${homePath}/.puter_site_config`,
Buffer.from(JSON.stringify({ errors: {}, marker: 'cfg-body' })),
'application/json',
);
await withCollationAlias(
`${homePath}/.püter_site_config`,
`${homePath}/.puter_site_config`,
async () => {
const out = await request(sub, '/.p%C3%BCter_site_config');
expect(out.statusCode).toBe(404);
expect(String(out.body)).not.toContain('cfg-body');
},
);
});
});
describe('createPuterSiteMiddleware — hosting CSP', () => {
+22 -1
View File
@@ -94,6 +94,20 @@ const isWorkersSourcePath = (urlPath: string): boolean =>
.split('/')
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
/**
* Checks the stored path of a looked-up entry. The URL-level guards are not
* enough on MySQL: `fsentries.path` is utf8mb4_unicode_ci, so `__wörkers`,
* fullwidth or zero-width spellings resolve to the real `__workers` row.
*/
const isServableEntryPath = (
entryPath: string | null | undefined,
rootPath: string,
): boolean => {
if (!entryPath?.startsWith(`${rootPath}/`)) return false;
const relPath = entryPath.slice(rootPath.length);
return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath);
};
/** Inside some owner's top-level Trash, which is where Delete puts things. */
const isTrashedPath = (path: string): boolean =>
/^\/[^/]+\/Trash(\/|$)/u.test(path);
@@ -620,6 +634,9 @@ export const createPuterSiteMiddleware = (
pathPosix.join(filePath, 'index.html'),
);
}
if (entry && !isServableEntryPath(entry.path, rootPath)) {
entry = null;
}
// Custom error page fallback. On a 404 we consult the site config
// for a rule and, if one resolves to an existing file, serve that
@@ -640,7 +657,11 @@ export const createPuterSiteMiddleware = (
const candidate = await layers.stores.fsEntry.getEntryByPath(
errorTarget.absPath,
);
if (candidate && !candidate.isDir) {
if (
candidate &&
!candidate.isDir &&
isServableEntryPath(candidate.path, rootPath)
) {
entry = candidate;
statusOverride = errorTarget.status;
}
+14 -9
View File
@@ -1867,12 +1867,16 @@ export class AuthService extends PuterService {
/**
* Materialize the `kind='asset'` session row that the cookie's
* `session_uuid` claim points at. Parented to the web session so a logout
* cascade kills every asset cookie minted under it. Both fields are `null`
* only when the caller didn't supply a web session at all — the cookie
* still mints unparented and without an `auth_id` claim (matches v1
* behavior for access-token-minted cookies that aren't tied to an
* interactive session).
* `session_uuid` claim points at. Parented to the minting session so
* revoking it kills the cookie. The cascade is one level deep, so a cookie
* minted from a godmode token (itself under a web session) outlives logout
* of that web session; accepted, as app tokens outlive it too and the
* cookie only serves one app's files after an entitlement check.
*
* Both fields are `null` only when the caller didn't supply a session at
* all — the cookie still mints unparented and without an `auth_id` claim
* (matches v1 behavior for access-token-minted cookies that aren't tied to
* an interactive session).
*
* If the caller DID supply a `webSessionUuid` but the lookup misses (row
* revoked / expired between mint request and this lookup), throw —
@@ -1907,9 +1911,10 @@ export class AuthService extends PuterService {
/**
* Verify a hosted-asset cookie of `kind` against what the request expects,
* and that the session it was minted under still exists and isn't revoked:
* `getByUuid` filters on `revoked_at IS NULL`, so a logout cascade
* invalidates every cookie minted under that web session. A cookie minted
* without a session (from an access-token actor) has nothing to bind.
* `getByUuid` filters on `revoked_at IS NULL`, so revoking the minting
* session invalidates the cookie. Only the leaf row is checked; see
* `#mintAssetSessionContext` for the godmode case. A cookie minted without
* a session (from an access-token actor) has nothing to bind.
*/
async verifyHostedAssetToken(
kind: HostedAssetKind,
@@ -4533,6 +4533,14 @@ describe('FSService move', () => {
}),
);
expect(intoItself.legacyCode).toBe('cannot_move_directory_into_itself');
const ontoItself = await caught(() =>
fs.move(user.userId, {
source: dir,
destinationParent: dir,
}),
);
expect(ontoItself.legacyCode).toBe('cannot_move_directory_into_itself');
});
it('handles a destination collision by conflict, overwrite or dedupe', async () => {
+2 -1
View File
@@ -4901,7 +4901,8 @@ export class FSService extends PuterService {
}
if (
source.isDir &&
destinationParent.path.startsWith(`${source.path}/`)
(destinationParent.uuid === source.uuid ||
destinationParent.path.startsWith(`${source.path}/`))
) {
throw new HttpError(400, 'Cannot move a directory into itself', {
legacyCode: 'cannot_move_directory_into_itself',
+8 -8
View File
@@ -399,11 +399,7 @@ export class SocketService extends PuterService {
const base = this.#authOptions();
const id = actor?.user?.id;
if (typeof id !== 'number' || actor?.user?.otp_enabled) return base;
const seat = await (
this.stores.team as unknown as {
getOrgSeat?: (n: number) => Promise<{ require_2fa?: number }>;
}
)
const seat = await this.stores.team
?.getOrgSeat?.(id)
.catch((): null => null);
return { ...base, teamRequires2fa: Number(seat?.require_2fa) === 1 };
@@ -423,7 +419,8 @@ export class SocketService extends PuterService {
// `{ auth: { ... } }`, not the query string. puter-js uses
// `io(url, { auth: { auth_token } })`.
const handshakeAuth = socket.handshake.auth as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const tokenRaw =
typeof handshakeAuth?.auth_token === 'string'
? handshakeAuth.auth_token
@@ -932,7 +929,8 @@ export class SocketService extends PuterService {
// their next poll of /cache/last-change-timestamp.
const originalSocketId = (
data.response as
{ original_client_socket_id?: string } | undefined
| { original_client_socket_id?: string }
| undefined
)?.original_client_socket_id;
await this.send({ room: userId }, 'cache.updated', {
timestamp,
@@ -946,7 +944,9 @@ export class SocketService extends PuterService {
#handleUploadProgress(data: UploadProgressPayload): void {
const meta = data.meta ?? {};
const userId = (meta.user_id ?? meta.userId) as
number | string | undefined;
| number
| string
| undefined;
if (!userId) {
console.warn('[socket] upload-progress missing user_id', { meta });
return;
+4 -1
View File
@@ -152,7 +152,10 @@ const RESERVED_HANDLES = new Set([
]);
export type HandleRejection =
'too_short' | 'too_long' | 'malformed' | 'reserved';
| 'too_short'
| 'too_long'
| 'malformed'
| 'reserved';
/** Trimmed and capped, so the same name is accepted on every engine. */
export const normalizeTeamName = (name: string): string => {
+48 -89
View File
@@ -505,49 +505,64 @@ const ipc_listener = async (event, handled) => {
// app-side promise only settles when a message with its id comes back
// — so a rejected call is answered anyway rather than left hanging
// forever. Logged here because that is the only place it is visible.
puter.kv.set({
key: event.data.key,
value: event.data.value,
app_uid: app_uuid,
}).catch(err => console.warn('kv.setItem failed for app:', err)).then(() => {
// send confirmation to requester window
target_iframe.contentWindow.postMessage({
original_msg_id: msg_id,
}, '*');
});
// These run on the user's session, so without an explicit app namespace
// they would land in the desktop's own account-wide settings.
if ( app_uuid ) {
try {
await puter.kv.set({
key: event.data.key,
value: event.data.value,
optConfig: { appUuid: app_uuid },
});
} catch ( err ) {
console.warn('kv.setItem failed for app:', err);
}
}
// send confirmation to requester window
target_iframe.contentWindow.postMessage({
original_msg_id: msg_id,
}, '*');
}
//--------------------------------------------------------
// getItem
//--------------------------------------------------------
else if ( event.data.msg === 'getItem' && event.data.key ) {
puter.kv.get({
key: event.data.key,
app_uid: app_uuid,
}).catch(err => {
console.warn('kv.getItem failed for app:', err);
return null;
}).then((result) => {
// send confirmation to requester window
target_iframe.contentWindow.postMessage({
original_msg_id: msg_id,
msg: 'getItemSucceeded',
value: result ?? null,
}, '*');
});
let result = null;
if ( app_uuid ) {
try {
result = await puter.kv.get({
key: event.data.key,
optConfig: { appUuid: app_uuid },
});
} catch ( err ) {
console.warn('kv.getItem failed for app:', err);
}
}
// send confirmation to requester window
target_iframe.contentWindow.postMessage({
original_msg_id: msg_id,
msg: 'getItemSucceeded',
value: result ?? null,
}, '*');
}
//--------------------------------------------------------
// removeItem
//--------------------------------------------------------
else if ( event.data.msg === 'removeItem' && event.data.key ) {
puter.kv.del({
key: event.data.key,
app_uid: app_uuid,
}).catch(err => console.warn('kv.removeItem failed for app:', err)).then(() => {
// send confirmation to requester window
target_iframe.contentWindow.postMessage({
original_msg_id: msg_id,
}, '*');
});
if ( app_uuid ) {
try {
await puter.kv.del({
key: event.data.key,
optConfig: { appUuid: app_uuid },
});
} catch ( err ) {
console.warn('kv.removeItem failed for app:', err);
}
}
// send confirmation to requester window
target_iframe.contentWindow.postMessage({
original_msg_id: msg_id,
}, '*');
}
//--------------------------------------------------------
// showOpenFilePicker
@@ -1522,62 +1537,6 @@ const ipc_listener = async (event, handled) => {
}, '*');
$(target_iframe).get(0).focus({ preventScroll: true });
}
//--------------------------------------------------------
// setWallpaper
//--------------------------------------------------------
else if ( event.data.msg === 'setWallpaper' ) {
// Auth
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
{
return;
}
// No options?
if ( ! event.data.options )
{
event.data.options = {};
}
// /set-desktop-bg
try {
await $.ajax({
url: `${window.api_origin }/set-desktop-bg`,
type: 'POST',
data: JSON.stringify({
url: event.data.readURL,
fit: event.data.options.fit ?? 'cover',
color: event.data.options.color,
}),
async: true,
contentType: 'application/json',
headers: {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function (xhr) {
window.handle401(xhr);
},
},
});
// Set wallpaper
window.set_desktop_background({
url: event.data.readURL,
fit: event.data.options.fit ?? 'cover',
color: event.data.options.color,
});
// Send success to app
target_iframe.contentWindow.postMessage({
msg: 'wallpaperSet',
original_msg_id: msg_id,
}, '*');
$(target_iframe).get(0).focus({ preventScroll: true });
} catch ( err ) {
console.error(err);
}
}
//--------------------------------------------------------
// showSaveFilePicker
//--------------------------------------------------------
+4 -3
View File
@@ -293,11 +293,12 @@ const TabFiles = {
puter.kv.get('sort_direction').catch(() => null),
puter.kv.get('column_widths').catch(() => null),
]);
this.currentView = savedView || 'list';
// Unknown stored values fall back to the defaults; sortColumn ends up in a selector.
this.currentView = ['list', 'grid', 'grid-sm'].includes(savedView) ? savedView : 'list';
// Sorting state
this.sortColumn = savedSortColumn || 'name';
this.sortDirection = savedSortDirection || 'asc';
this.sortColumn = ['name', 'size', 'modified'].includes(savedSortColumn) ? savedSortColumn : 'name';
this.sortDirection = savedSortDirection === 'desc' ? 'desc' : 'asc';
// Column widths state (for resizing). A corrupt saved value falls back
// to the defaults instead of taking the whole tab down with it.
+9 -1
View File
@@ -722,7 +722,15 @@ async function UIDesktop (options) {
// load window sidebar items from KV
puter.kv.get('sidebar_items').then(async (val) => {
window.sidebar_items = val;
// UIWindow parses this and reads `item.path` while building every
// explorer window, so a malformed value is dropped for the defaults.
let items = null;
try {
items = JSON.parse(val);
} catch { /* keep the defaults */ }
if ( Array.isArray(items) && items.every(item => typeof item?.path === 'string') ) {
window.sidebar_items = val;
}
// Catch on the chain rather than the read, so a failure leaves whatever is
// already there instead of overwriting it with a fallback.
}).catch(err => console.warn('Could not load sidebar_items:', err));
+5 -5
View File
@@ -24,6 +24,8 @@ import launch_app from '../helpers/launchApp.js';
import UIContextMenu from './UIContextMenu.js';
import { appIconAttrs, appIconSrc } from '../helpers/appIcon.js';
const TASKBAR_POSITIONS = ['left', 'bottom', 'right'];
async function UITaskbar (options) {
window.global_element_id++;
@@ -40,7 +42,8 @@ async function UITaskbar (options) {
taskbar_position = await puter.kv.get('taskbar_position').catch(() => null);
// if this is not first visit, set taskbar position to bottom since it's from a user that
// used puter before customizing taskbar position was added and the taskbar position was set to bottom
if ( ! taskbar_position ) {
// Unknown values are discarded too: this ends up in markup and stored values may be poisoned.
if ( ! TASKBAR_POSITIONS.includes(taskbar_position) ) {
taskbar_position = 'bottom'; // default position
puter.kv.set('taskbar_position', taskbar_position).catch(err => console.warn('Could not save taskbar_position:', err));
}
@@ -501,10 +504,7 @@ window.update_taskbar_position = async function (new_position) {
return;
}
// Valid positions
const valid_positions = ['left', 'bottom', 'right'];
if ( ! valid_positions.includes(new_position) ) {
if ( ! TASKBAR_POSITIONS.includes(new_position) ) {
return;
}
+4 -1
View File
@@ -708,7 +708,10 @@ window.mutate_user_preferences = function (user_preferences_delta) {
window.update_user_preferences = function (user_preferences) {
window.user_preferences = user_preferences;
localStorage.setItem('user_preferences', JSON.stringify(user_preferences));
const language = user_preferences.language ?? 'en';
// The stored value can be anything, and i18n/Intl throw on some bad locales.
const language = window.listSupportedLanguages().some(lang => lang.code === user_preferences.language)
? user_preferences.language
: 'en';
window.locale = language;
// Broadcast locale change to apps
+3 -1
View File
@@ -60,7 +60,9 @@ export const sanitize_recents = (value) => {
out.push({
kind: entry.kind,
id,
name: typeof entry.name === 'string' && entry.name.trim() !== ''
// A person is shown by the id the share goes to, so a stored name
// can't present one recipient as another.
name: entry.kind === 'team' && typeof entry.name === 'string' && entry.name.trim() !== ''
? entry.name
: id,
});
+12
View File
@@ -37,6 +37,18 @@ describe('sanitize_recents', () => {
.toEqual([{ kind: 'user', id: 'bob', name: 'bob' }]);
});
it('shows a person or invite by their id, whatever name was stored', () => {
expect(sanitize_recents([
{ kind: 'user', id: 'bob', name: 'alice' },
{ kind: 'invite', id: 'ann@example.com', name: 'carol@example.com' },
{ kind: 'team', id: 'team-1', name: 'Design' },
])).toEqual([
BOB,
ANN,
{ kind: 'team', id: 'team-1', name: 'Design' },
]);
});
it('keeps one entry per recipient, the first', () => {
expect(sanitize_recents([
{ kind: 'user', id: 'bob', name: 'bob' },