Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/backend/clients/event/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* the SocketService fans out to user-scoped channels.
*/

import { Actor } from '../../core';

// GUI write events spread an entry plus per-event metadata into `response`.
// The exact field set varies by emit site (FSController / LegacyFSController /
// WebDAVController each project a slightly different shape) so the envelope
Expand Down Expand Up @@ -96,8 +98,11 @@ export type EventMap = {
};
'app.from-origin': { origin: string };
'app.privateAccess.check': {
app: unknown;
actor: unknown;
appUid: string;
userUid: string;
requestHost: string;
requestPath: string;
actor?: Actor;
result: {
allowed: boolean;
reason?: string;
Expand Down
45 changes: 42 additions & 3 deletions src/backend/core/http/middleware/puterSite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(String(out.body)).toContain('Not Found');
});

it('returns 404 when the resolved URL points at a directory rather than a file', async () => {
it('returns 404 when the resolved URL points at a directory with no index.html', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
Expand All @@ -645,8 +645,8 @@ describe('createPuterSiteMiddleware — file serving', () => {
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
// Documents/ exists from generateDefaultFsentries — a
// directory entry, which the file branch must refuse.
// Documents/ exists from generateDefaultFsentries but has
// no index.html — directory fallback must still 404.
path: '/Documents',
}),
res,
Expand All @@ -657,6 +657,45 @@ describe('createPuterSiteMiddleware — file serving', () => {
expect(out.contentType).toBe('text/html; charset=UTF-8');
});

it('serves <folder>/index.html when the URL resolves to a folder containing one', async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `folderidx-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
const body = Buffer.from('nested doc');
await writeFile(
owner.id,
`${homePath}/Documents/index.html`,
body,
'text/html',
);

const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
// No trailing slash; folder exists and contains index.html.
path: '/Documents',
}),
res,
vi.fn(),
);
// Allow the piped stream to flush.
await new Promise<void>((resolve) => setImmediate(resolve));

expect(out.statusCode).toBe(200);
expect(out.headers['Content-Type']).toMatch(/text\/html/);
const piped = out.body as Buffer | undefined;
expect(Buffer.isBuffer(piped)).toBe(true);
expect(piped!.equals(body)).toBe(true);
});

it("serves the HTML SUBDOMAIN_404 when the subdomain's root_dir_id points to a missing entry", async () => {
// Subdomain row references a fsentry id that doesn't exist —
// earlier path resolution must catch this with the HTML 404 page.
Expand Down
20 changes: 18 additions & 2 deletions src/backend/core/http/middleware/puterSite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,16 @@ export const createPuterSiteMiddleware = (
// Resolve URL path → absolute FS path under the site root.
let urlPath = req.path || '/';
if (urlPath.endsWith('/')) urlPath += 'index.html';
const decoded = decodeURIComponent(urlPath);
let decoded: string;
try {
decoded = decodeURIComponent(urlPath);
} catch {
// Malformed `%xx` escape — treat as a missing path, not a 500.
res.status(404)
.type('text/html; charset=UTF-8')
.send('<h1>404</h1><p>Not Found</p>');
return;
}
// pathPosix.normalize strips `..` segments; the join with '/' anchors
// it so traversal can't escape the site root.
const resolvedUrlPath = pathPosix.normalize(
Expand All @@ -452,7 +461,14 @@ export const createPuterSiteMiddleware = (
// Subdomain hosting bypasses ACL by design: anything the owner placed
// under the registered root_dir is treated as public. Path traversal
// is blocked above by `pathPosix.normalize` anchoring at `/`.
const entry = await layers.stores.fsEntry.getEntryByPath(filePath);
let entry = await layers.stores.fsEntry.getEntryByPath(filePath);
if (entry?.isDir) {
// Folder request → fall back to <folder>/index.html, the same
// way `/` is rewritten to `/index.html` at the site root above.
entry = await layers.stores.fsEntry.getEntryByPath(
pathPosix.join(filePath, 'index.html'),
);
}
if (!entry || entry.isDir) {
res.status(404)
.type('text/html; charset=UTF-8')
Expand Down
Loading