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: 1 addition & 8 deletions frontend/src/components/editor/file-tree/upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { type DropzoneOptions, useDropzone } from "react-dropzone";
import { toast } from "@/components/ui/use-toast";
import { useRequestClient } from "@/core/network/requests";
import { serializeBlob } from "@/utils/blob";
import { withLoadingToast } from "@/utils/download";
import { Logger } from "@/utils/Logger";
import { type FilePath, PathBuilder } from "@/utils/paths";
Expand Down Expand Up @@ -69,17 +68,11 @@ export function useFileExplorerUpload(options: DropzoneOptions = {}) {
PathBuilder.guessDeliminator(filePath).dirname(filePath);
}

// File contents are sent base64-encoded to support arbitrary
// bytes data
//
// get the raw base64-encoded data from a string starting with
// data:*/*;base64,
const base64 = (await serializeBlob(file)).split(",")[1];
await sendCreateFileOrFolder({
path: directoryPath,
type: "file",
name: file.name,
contents: base64,
file,
});
progress.increment(1);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ describe("insertImage", () => {
path: "public",
type: "file",
name: "hello.png",
contents: "AQID",
file: expect.any(File),
});

expect(view.state.doc.toString()).toMatchInlineSnapshot(
Expand Down Expand Up @@ -291,7 +291,7 @@ describe("insertImage", () => {
path: "nested/public", // store in public folder of notebook directory
type: "file",
name: "hello.png",
contents: "AQID",
file: expect.any(File),
});

expect(view.state.doc.toString()).toMatchInlineSnapshot(
Expand Down Expand Up @@ -337,7 +337,7 @@ describe("insertImage", () => {
path: "/Users/user/Development/project/public",
type: "file",
name: "hello.png",
contents: "AQID",
file: expect.any(File),
});

// Should convert absolute path to relative path
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/core/codemirror/markdown/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ export async function insertImage(view: EditorView, file: File) {
// If the file is base64 encoded, we can save it locally to prevent large file strings
try {
if (dataUrl.startsWith("data:")) {
const base64 = dataUrl.split(",")[1];
let inputFilename = prompt(
"We can save your image as a file. Enter a filename.",
file.name,
Expand Down Expand Up @@ -348,7 +347,7 @@ export async function insertImage(view: EditorView, file: File) {
path: publicFolderPath as FilePath,
type: "file",
name: inputFilename,
contents: base64,
file,
});

if (createFileRes.success) {
Expand Down
24 changes: 21 additions & 3 deletions frontend/src/core/network/requests-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import { API, createClientWithRuntimeManager } from "./api";
import { waitForConnectionOpen } from "./connection";
import type { EditRequests, RunRequests } from "./types";

/**
* Options for POSTing FormData via openapi-fetch. openapi-fetch types
* request bodies from the JSON schema, so we bypass the body type and
* override the serializer to pass the FormData through unchanged; the
* browser then sets the multipart Content-Type with boundary.
*/
function multipartInit(formData: FormData) {
return {
body: formData as never,
bodySerializer: (body: unknown) => body as never,
};
}

const { handleResponse, handleResponseReturnNull } = API;

export function createNetworkRequests(): EditRequests & RunRequests {
Expand Down Expand Up @@ -298,10 +311,15 @@ export function createNetworkRequests(): EditRequests & RunRequests {
},
sendCreateFileOrFolder: async (request) => {
await waitForConnectionOpen();
const formData = new FormData();
formData.append("path", request.path);
formData.append("type", request.type);
formData.append("name", request.name);
if (request.file) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we want to make the request if there is no file?

formData.append("file", request.file, request.name);
}
return getClient()
.POST("/api/files/create", {
body: request,
})
.POST("/api/files/create", multipartInit(formData))
.then(handleResponse);
},
sendDeleteFileOrFolder: async (request) => {
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/core/network/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ export type SaveUserConfigurationRequest =
export interface SetCellConfigRequest {
configs: Record<CellId, Partial<CellConfig>>;
}
/**
* Client-side shape for creating a file/directory/notebook. The HTTP
* transport sends this as multipart/form-data; the WASM bridge base64-encodes
* `file` internally and crosses the JS<->Py boundary as JSON.
*/
export interface FileCreateInput {
path: string;
type: "file" | "directory" | "notebook";
name: string;
file?: Blob;
}
export type UpdateUIElementRequest = schemas["UpdateUIElementRequest"];
export type ModelRequest = schemas["ModelRequest"];
export type NotebookDocumentTransactionRequest =
Expand Down Expand Up @@ -165,7 +176,7 @@ export interface EditRequests {
sendListFiles: (request: FileListRequest) => Promise<FileListResponse>;
sendSearchFiles: (request: FileSearchRequest) => Promise<FileSearchResponse>;
sendCreateFileOrFolder: (
request: FileCreateRequest,
request: FileCreateInput,
) => Promise<FileCreateResponse>;
sendDeleteFileOrFolder: (
request: FileDeleteRequest,
Expand Down
15 changes: 14 additions & 1 deletion frontend/src/core/wasm/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { toast } from "@/components/ui/use-toast";
import { userConfigAtom } from "@/core/config/config";
import { serializeBlob } from "@/utils/blob";
import { Deferred } from "@/utils/Deferred";
import { throwNotImplemented } from "@/utils/functions";
import { Logger } from "@/utils/Logger";
Expand Down Expand Up @@ -431,9 +432,21 @@ export class PyodideBridge implements RunRequests, EditRequests {
sendCreateFileOrFolder: EditRequests["sendCreateFileOrFolder"] = async (
request,
) => {
// The WASM RPC boundary can only carry JSON, so we base64-encode the
// file bytes here. The HTTP transport uses multipart/form-data instead.
let contents: string | null = null;
if (request.file) {
const dataUrl = await serializeBlob(request.file);
contents = dataUrl.split(",")[1] ?? "";
}
const response = await this.rpc.proxy.request.bridge({
functionName: "create_file_or_directory",
payload: request,
payload: {
path: request.path,
type: request.type,
name: request.name,
contents,
},
});
return response as FileCreateResponse;
};
Expand Down
1 change: 1 addition & 0 deletions marimo/_cli/development/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ def _generate_server_api_schema() -> dict[str, Any]:
export.ExportAsIPYNBRequest,
export.ExportAsPDFRequest,
export.UpdateCellOutputsRequest,
files.FileCreateMultipartRequest,
files.FileCreateRequest,
files.FileCreateResponse,
files.FileDeleteRequest,
Expand Down
22 changes: 10 additions & 12 deletions marimo/_server/api/endpoints/file_explorer.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

import base64
from typing import TYPE_CHECKING

from starlette.authentication import requires

from marimo import _loggers
from marimo._server.api.deps import AppState
from marimo._server.api.utils import parse_request
from marimo._server.api.utils import parse_multipart_request, parse_request
from marimo._server.files.os_file_system import OSFileSystem
from marimo._server.models.files import (
FileCopyRequest,
FileCopyResponse,
FileCreateRequest,
FileCreateMultipartRequest,
FileCreateResponse,
FileDeleteRequest,
FileDeleteResponse,
Expand Down Expand Up @@ -109,9 +108,9 @@ async def create_file_or_directory(
"""
requestBody:
content:
application/json:
multipart/form-data:
schema:
$ref: "#/components/schemas/FileCreateRequest"
$ref: "#/components/schemas/FileCreateMultipartRequest"
responses:
200:
description: Create a new file or directory
Expand All @@ -120,16 +119,15 @@ async def create_file_or_directory(
schema:
$ref: "#/components/schemas/FileCreateResponse"
"""
body = await parse_request(request, cls=FileCreateRequest)
try:
decoded_contents = (
base64.b64decode(body.contents)
if body.contents is not None
else None
parsed = await parse_multipart_request(
request, FileCreateMultipartRequest
)

info = file_system.create_file_or_directory(
body.path, body.type, body.name, decoded_contents
parsed.body.path,
parsed.body.type,
parsed.body.name,
parsed.files.get("file"),
)
return FileCreateResponse(success=True, info=info)
except Exception as e:
Expand Down
46 changes: 46 additions & 0 deletions marimo/_server/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@
import subprocess
import sys
import webbrowser
from dataclasses import dataclass
from pathlib import Path
from shutil import which
from typing import (
TYPE_CHECKING,
Any,
Generic,
Protocol,
TypeVar,
runtime_checkable,
)

import msgspec

from marimo._runtime.commands import CommandMessage
from marimo._server.models.models import SuccessResponse
from marimo._types.ids import ConsumerId
Expand All @@ -34,6 +39,47 @@ async def parse_request(
)


S = TypeVar("S", bound=msgspec.Struct)


@dataclass
class MultipartRequest(Generic[S]):
"""Result of parsing a multipart/form-data request body."""

body: S
files: dict[str, bytes]


async def parse_multipart_request(
request: Request, cls: type[S]
) -> MultipartRequest[S]:
"""Parse a multipart/form-data body into a msgspec.Struct + file bytes.

String form fields are validated against `cls`. File upload parts are
read fully into memory and returned in `files`, keyed by form-field
name (callers look them up explicitly rather than via the struct).

Raises msgspec.ValidationError if required string fields are missing
or invalid.
"""
# Imported lazily so this module stays import-safe in environments
# without starlette (e.g. pyodide).
from starlette.datastructures import UploadFile

# Use as an async context manager so any spooled temp files backing
# UploadFile parts are closed after parsing.
async with request.form() as form:
string_payload: dict[str, Any] = {}
files: dict[str, bytes] = {}
for key, value in form.multi_items():
if isinstance(value, UploadFile):
files[key] = await value.read()
elif isinstance(value, str):
string_payload[key] = value
body = msgspec.convert(string_payload, cls, strict=False)
return MultipartRequest(body=body, files=files)


@runtime_checkable
class RequestAsCommand(Protocol):
"""Protocol for requests that can be converted to commands."""
Expand Down
13 changes: 13 additions & 0 deletions marimo/_server/files/os_file_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ def create_file_or_directory(
)
if name.strip() == "":
raise ValueError("Cannot create file or directory with empty name")
# Names that traverse out of `path` or escape via separators are
# rejected. Validation belongs here (not in the endpoint) so every
# caller of OSFileSystem — HTTP, WASM bridge, scripts — is covered.
if (
"/" in name
or "\\" in name
or "\x00" in name
or name in (".", "..")
):
raise ValueError(
f"Invalid name {name!r}: must not contain path separators "
"or refer to a parent directory"
Comment thread
mscolnick marked this conversation as resolved.
)

full_path = Path(path) / name
full_path = _generate_unique_path(full_path)
Expand Down
26 changes: 23 additions & 3 deletions marimo/_server/models/files.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations

from typing import Literal
from typing import Annotated, Literal

import msgspec

from marimo._metadata.opengraph import OpenGraphMetadata
from marimo._server.models.models import BaseResponse

FileCreateType = Literal["file", "directory", "notebook"]


class FileInfo(msgspec.Struct, rename="camel"):
id: str
Expand Down Expand Up @@ -46,13 +48,31 @@ class FileCreateRequest(msgspec.Struct, rename="camel"):
# The path where to create the file or directory
path: str
# 'file', 'directory', or 'notebook'
type: Literal["file", "directory", "notebook"]
type: FileCreateType
# The name of the file or directory
name: str
# The contents of the file, base64-encoded
# The contents of the file, base64-encoded. Used by the WASM/Pyodide RPC
# bridge, which cannot send multipart over the JS<->Py JSON boundary.
# The HTTP endpoint instead receives the raw file bytes via multipart/form-data.
contents: str | None = None


class FileCreateMultipartRequest(msgspec.Struct, rename="camel"):
"""multipart/form-data body for POST /api/files/create."""

# The path where to create the file or directory
path: str
# 'file', 'directory', or 'notebook'
type: FileCreateType
# The name of the file or directory
name: str
# The raw file bytes (optional). When omitted, an empty file is created
# (or, for 'notebook' type, a default notebook template).
Comment thread
mscolnick marked this conversation as resolved.
file: Annotated[
str | None, msgspec.Meta(extra_json_schema={"format": "binary"})
] = None


class FileSearchRequest(msgspec.Struct, rename="camel"):
# The search query string
query: str
Expand Down
Loading
Loading