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
6 changes: 2 additions & 4 deletions e2e-tests/site.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ test("Model page loads", async ({ page }) => {
page.getByRole("heading", { name: "Malloy model invoices" }),
).toBeVisible({ timeout: 15 * 1000 });
// Verify data sources section is visible
await expect(
page.getByRole("button", { name: /Data Sources/ }),
).toBeVisible();
await expect(page.getByRole("link", { name: /Data Sources/ })).toBeVisible();
});

test("Preview page loads", async ({ page }) => {
Expand All @@ -28,7 +26,7 @@ test("Preview page loads", async ({ page }) => {
});
// Verify preview page loaded with heading and table data
await expect(page.getByRole("heading", { name: "invoices" })).toBeVisible();
await expect(page.getByText("Preview")).toBeVisible();
await expect(page.getByRole("button", { name: "Preview" })).toBeVisible();
// Verify table has data
await expect(page.getByText("invoice_id")).toBeVisible();
});
Expand Down
2 changes: 1 addition & 1 deletion img/chevron_down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
185 changes: 185 additions & 0 deletions plugins/vite-plugin-copy-downloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Vite plugin to copy downloadable files (models, notebooks, data)
* to the build output for static serving
*/
import type { Plugin } from "vite";
import {
copyFileSync,
mkdirSync,
existsSync,
readdirSync,
statSync,
createReadStream,
} from "node:fs";
import { join, resolve, basename } from "node:path";

export default function copyDownloadsPlugin(): Plugin {
let outDir: string;
let modelsDir: string;

return {
name: "vite-plugin-copy-downloads",

configResolved(config) {
outDir = resolve(config.root, config.build.outDir);
modelsDir = resolve(config.root, "models");
},

configureServer(server) {
// Respect Vite's base config
const base = server.config.base.endsWith("/")
? server.config.base
: `${server.config.base}/`;
const downloadsPrefix = `${base}downloads/`;

server.middlewares.use((req, res, next) => {
const url = req.url || "";

if (!url.startsWith(downloadsPrefix)) {
next();
return;
}

// Strip the prefix and any query string, then decode
const pathPart = url.slice(downloadsPrefix.length).split("?")[0] || "";
const [rawCategory, ...rawRestParts] = pathPart
.split("/")
.filter(Boolean);

if (!rawCategory || rawRestParts.length === 0) {
next();
return;
}

// Decode URI components
const category = decodeURIComponent(rawCategory);
const restParts = rawRestParts.map((part) => decodeURIComponent(part));

Comment on lines +54 to +57
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decodeURIComponent(rawCategory) / decodeURIComponent(part) can throw a URIError on malformed percent-encoding in the request URL, which would bubble out of the middleware and can crash the dev server. Wrap the decoding in a try/catch and respond with 400 (or next()), rather than throwing.

Copilot uses AI. Check for mistakes.
// Reject any path traversal attempts
if (restParts.some((part) => part === ".." || part === ".")) {
res.statusCode = 400;
res.end("Bad Request");
return;
}

const rest = restParts.join("/");
let filePath: string;

if (category === "models" || category === "notebooks") {
// Models and notebooks both live in the top-level models directory.
filePath = join(modelsDir, rest);
} else if (category === "data") {
filePath = join(modelsDir, "data", rest);
} else {
next();
return;
}

// Verify the resolved path is within the allowed directory
const normalizedPath = resolve(filePath);
const allowedDir =
category === "data" ? resolve(modelsDir, "data") : resolve(modelsDir);
if (!normalizedPath.startsWith(allowedDir)) {
res.statusCode = 403;
res.end("Forbidden");
return;
}
Comment on lines +54 to +86
Copy link

Copilot AI Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path validation in the dev middleware is bypassable: decodeURIComponent can introduce / inside a segment (e.g. %2e%2e%2fmodels-secret/file -> ../models-secret/file), which is not caught by the part === ".." check. Combined with normalizedPath.startsWith(allowedDir), this can allow reads outside modelsDir due to prefix matches (e.g. /repo/models-secret startsWith /repo/models). Consider resolving against allowedDir and verifying containment via path.relative(allowedDir, candidate) (must not start with .. or be absolute), and/or rejecting decoded segments containing / or \\.

Copilot uses AI. Check for mistakes.

if (!existsSync(filePath)) {
next();
return;
}

const stat = statSync(filePath);
if (!stat.isFile()) {
next();
return;
}

res.statusCode = 200;
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${basename(filePath)}"`,
);

const stream = createReadStream(filePath);
stream.on("error", () => {
if (!res.headersSent) {
res.statusCode = 500;
res.end("Internal Server Error");
} else {
res.end();
}
});
stream.pipe(res);
});
},

closeBundle() {
// Skip during test runs
if (process.env["VITEST"] || process.env["NODE_ENV"] === "test") {
return;
}
const downloadsDir = join(outDir, "downloads");

// Create downloads directory structure
const modelsDest = join(downloadsDir, "models");
const notebooksDest = join(downloadsDir, "notebooks");
const dataDest = join(downloadsDir, "data");

[downloadsDir, modelsDest, notebooksDest, dataDest].forEach((dir) => {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
});

// Copy all .malloy files (models)
const files = readdirSync(modelsDir);
let modelCount = 0;
let notebookCount = 0;

files.forEach((file) => {
const srcPath = join(modelsDir, file);
const stat = statSync(srcPath);

if (stat.isFile()) {
if (file.endsWith(".malloy")) {
const destPath = join(modelsDest, file);
copyFileSync(srcPath, destPath);
modelCount++;
console.log(` ✓ Copied model: ${file}`);
} else if (file.endsWith(".malloynb")) {
const destPath = join(notebooksDest, file);
copyFileSync(srcPath, destPath);
notebookCount++;
console.log(` ✓ Copied notebook: ${file}`);
}
}
});

// Copy all data files from models/data
const dataDir = join(modelsDir, "data");
if (existsSync(dataDir)) {
const dataFiles = readdirSync(dataDir);
let dataCount = 0;

dataFiles.forEach((file) => {
const srcPath = join(dataDir, file);
const stat = statSync(srcPath);

if (stat.isFile() && file !== ".gitkeep") {
const destPath = join(dataDest, file);
copyFileSync(srcPath, destPath);
dataCount++;
console.log(` ✓ Copied data: ${file}`);
}
});

console.log(
`\n📦 Download files copied: ${modelCount.toString()} models, ${notebookCount.toString()} notebooks, ${dataCount.toString()} data files`,
);
}
},
};
}
Loading
Loading