mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-25 11:45:47 +00:00
Compare commits
4 Commits
ci_python_
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3b82d15a | ||
|
|
fa65567d86 | ||
|
|
91674ae85c | ||
|
|
26d7461335 |
@@ -762,6 +762,43 @@ def download_webapp(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{session_id}/download-directory/{path:path}")
|
||||
def download_directory(
|
||||
session_id: UUID,
|
||||
path: str,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""
|
||||
Download a directory as a zip file.
|
||||
|
||||
Returns the specified directory as a zip archive.
|
||||
"""
|
||||
user_id: UUID = user.id
|
||||
session_manager = SessionManager(db_session)
|
||||
|
||||
try:
|
||||
result = session_manager.download_directory(session_id, user_id, path)
|
||||
except ValueError as e:
|
||||
error_message = str(e)
|
||||
if "path traversal" in error_message.lower():
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
raise HTTPException(status_code=400, detail=error_message)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
|
||||
zip_bytes, filename = result
|
||||
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/upload", response_model=UploadResponse)
|
||||
def upload_file_endpoint(
|
||||
session_id: UUID,
|
||||
|
||||
@@ -107,27 +107,23 @@ def get_or_create_craft_connector(db_session: Session, user: User) -> tuple[int,
|
||||
)
|
||||
|
||||
for cc_pair in cc_pairs:
|
||||
if cc_pair.connector.source == DocumentSource.CRAFT_FILE:
|
||||
if (
|
||||
cc_pair.connector.source == DocumentSource.CRAFT_FILE
|
||||
and cc_pair.creator_id == user.id
|
||||
):
|
||||
return cc_pair.connector.id, cc_pair.credential.id
|
||||
|
||||
# Check for orphaned connector (created but cc_pair creation failed previously)
|
||||
# No cc_pair for this user — find or create the shared CRAFT_FILE connector
|
||||
existing_connectors = fetch_connectors(
|
||||
db_session, sources=[DocumentSource.CRAFT_FILE]
|
||||
)
|
||||
orphaned_connector = None
|
||||
connector_id: int | None = None
|
||||
for conn in existing_connectors:
|
||||
if conn.name != USER_LIBRARY_CONNECTOR_NAME:
|
||||
continue
|
||||
if not conn.credentials:
|
||||
orphaned_connector = conn
|
||||
if conn.name == USER_LIBRARY_CONNECTOR_NAME:
|
||||
connector_id = conn.id
|
||||
break
|
||||
|
||||
if orphaned_connector:
|
||||
connector_id = orphaned_connector.id
|
||||
logger.info(
|
||||
f"Found orphaned User Library connector {connector_id}, completing setup"
|
||||
)
|
||||
else:
|
||||
if connector_id is None:
|
||||
connector_data = ConnectorBase(
|
||||
name=USER_LIBRARY_CONNECTOR_NAME,
|
||||
source=DocumentSource.CRAFT_FILE,
|
||||
|
||||
@@ -646,16 +646,30 @@ class SessionManager:
|
||||
|
||||
if sandbox and sandbox.status.is_active():
|
||||
# Quick health check to verify sandbox is actually responsive
|
||||
if self._sandbox_manager.health_check(sandbox.id, timeout=5.0):
|
||||
# AND verify the session workspace still exists on disk
|
||||
# (it may have been wiped if the sandbox was re-provisioned)
|
||||
is_healthy = self._sandbox_manager.health_check(sandbox.id, timeout=5.0)
|
||||
workspace_exists = (
|
||||
is_healthy
|
||||
and self._sandbox_manager.session_workspace_exists(
|
||||
sandbox.id, existing.id
|
||||
)
|
||||
)
|
||||
if is_healthy and workspace_exists:
|
||||
logger.info(
|
||||
f"Returning existing empty session {existing.id} for user {user_id}"
|
||||
)
|
||||
return existing
|
||||
else:
|
||||
elif not is_healthy:
|
||||
logger.warning(
|
||||
f"Empty session {existing.id} has unhealthy sandbox {sandbox.id}. "
|
||||
f"Deleting and creating fresh session."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Empty session {existing.id} workspace missing in sandbox "
|
||||
f"{sandbox.id}. Deleting and creating fresh session."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Empty session {existing.id} has no active sandbox "
|
||||
@@ -1903,6 +1917,94 @@ class SessionManager:
|
||||
|
||||
return zip_buffer.getvalue(), filename
|
||||
|
||||
def download_directory(
|
||||
self,
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
path: str,
|
||||
) -> tuple[bytes, str] | None:
|
||||
"""
|
||||
Create a zip file of an arbitrary directory in the session workspace.
|
||||
|
||||
Args:
|
||||
session_id: The session UUID
|
||||
user_id: The user ID to verify ownership
|
||||
path: Relative path to the directory (within session workspace)
|
||||
|
||||
Returns:
|
||||
Tuple of (zip_bytes, filename) or None if session not found
|
||||
|
||||
Raises:
|
||||
ValueError: If path traversal attempted or path is not a directory
|
||||
"""
|
||||
# Verify session ownership
|
||||
session = get_build_session(session_id, user_id, self._db_session)
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
sandbox = get_sandbox_by_user_id(self._db_session, user_id)
|
||||
if sandbox is None:
|
||||
return None
|
||||
|
||||
# Check if directory exists
|
||||
try:
|
||||
self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=path,
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Recursively collect all files
|
||||
def collect_files(dir_path: str) -> list[tuple[str, str]]:
|
||||
"""Collect all files recursively, returning (full_path, arcname) tuples."""
|
||||
files: list[tuple[str, str]] = []
|
||||
try:
|
||||
entries = self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=dir_path,
|
||||
)
|
||||
for entry in entries:
|
||||
if entry.is_directory:
|
||||
files.extend(collect_files(entry.path))
|
||||
else:
|
||||
# arcname is relative to the target directory
|
||||
prefix_len = len(path) + 1 # +1 for trailing slash
|
||||
arcname = entry.path[prefix_len:]
|
||||
files.append((entry.path, arcname))
|
||||
except ValueError:
|
||||
pass
|
||||
return files
|
||||
|
||||
file_list = collect_files(path)
|
||||
|
||||
# Create zip file in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for full_path, arcname in file_list:
|
||||
try:
|
||||
content = self._sandbox_manager.read_file(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=full_path,
|
||||
)
|
||||
zip_file.writestr(arcname, content)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# Use the directory name for the zip filename
|
||||
dir_name = Path(path).name
|
||||
safe_name = "".join(
|
||||
c if c.isalnum() or c in ("-", "_", ".") else "_" for c in dir_name
|
||||
)
|
||||
filename = f"{safe_name}.zip"
|
||||
|
||||
return zip_buffer.getvalue(), filename
|
||||
|
||||
# =========================================================================
|
||||
# File System Operations
|
||||
# =========================================================================
|
||||
@@ -1937,11 +2039,18 @@ class SessionManager:
|
||||
return None
|
||||
|
||||
# Use sandbox manager to list directory (works for both local and K8s)
|
||||
raw_entries = self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=path,
|
||||
)
|
||||
# If the directory doesn't exist (e.g., session workspace not yet loaded),
|
||||
# return an empty listing rather than erroring out.
|
||||
try:
|
||||
raw_entries = self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=path,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "path traversal" in str(e).lower():
|
||||
raise
|
||||
return DirectoryListing(path=path, entries=[])
|
||||
|
||||
# Filter hidden files and directories
|
||||
entries: list[FileSystemEntry] = [
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { SvgGlobe, SvgDownloadCloud } from "@opal/icons";
|
||||
import { SvgGlobe, SvgDownloadCloud, SvgFolder, SvgFiles } from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { Artifact } from "@/app/craft/hooks/useBuildSessionStore";
|
||||
import { useFilesNeedsRefresh } from "@/app/craft/hooks/useBuildSessionStore";
|
||||
import {
|
||||
fetchDirectoryListing,
|
||||
downloadArtifactFile,
|
||||
downloadDirectory,
|
||||
} from "@/app/craft/services/apiServices";
|
||||
import { getFileIcon } from "@/lib/utils";
|
||||
|
||||
interface ArtifactsTabProps {
|
||||
artifacts: Artifact[];
|
||||
@@ -20,20 +28,50 @@ export default function ArtifactsTab({
|
||||
(a) => a.type === "nextjs_app" || a.type === "web_app"
|
||||
);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!sessionId) return;
|
||||
// Fetch top-level items in outputs/ directory
|
||||
const filesNeedsRefresh = useFilesNeedsRefresh();
|
||||
const { data: outputsListing } = useSWR(
|
||||
sessionId
|
||||
? [
|
||||
`/api/build/sessions/${sessionId}/files?path=outputs`,
|
||||
filesNeedsRefresh,
|
||||
]
|
||||
: null,
|
||||
() => (sessionId ? fetchDirectoryListing(sessionId, "outputs") : null),
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 2000,
|
||||
}
|
||||
);
|
||||
|
||||
// Trigger download by creating a link and clicking it
|
||||
const downloadUrl = `/api/build/sessions/${sessionId}/webapp/download`;
|
||||
// Filter out the "web" directory since it's already shown as a webapp artifact
|
||||
const outputEntries = (outputsListing?.entries ?? []).filter(
|
||||
(entry) => entry.name !== "web"
|
||||
);
|
||||
|
||||
const handleWebappDownload = () => {
|
||||
if (!sessionId) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = ""; // Let the server set the filename
|
||||
link.href = `/api/build/sessions/${sessionId}/webapp/download`;
|
||||
link.download = "";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
if (!sessionId || webappArtifacts.length === 0) {
|
||||
const handleOutputDownload = (path: string, isDirectory: boolean) => {
|
||||
if (!sessionId) return;
|
||||
if (isDirectory) {
|
||||
downloadDirectory(sessionId, path);
|
||||
} else {
|
||||
downloadArtifactFile(sessionId, path);
|
||||
}
|
||||
};
|
||||
|
||||
const hasWebapps = webappArtifacts.length > 0;
|
||||
const hasOutputFiles = outputEntries.length > 0;
|
||||
|
||||
if (!sessionId || (!hasWebapps && !hasOutputFiles)) {
|
||||
return (
|
||||
<Section
|
||||
height="full"
|
||||
@@ -41,12 +79,12 @@ export default function ArtifactsTab({
|
||||
justifyContent="center"
|
||||
padding={2}
|
||||
>
|
||||
<SvgGlobe size={48} className="stroke-text-02" />
|
||||
<SvgFiles size={48} className="stroke-text-02" />
|
||||
<Text headingH3 text03>
|
||||
No web apps yet
|
||||
No artifacts yet
|
||||
</Text>
|
||||
<Text secondaryBody text02>
|
||||
Web apps created during the build will appear here
|
||||
Output files and web apps will appear here
|
||||
</Text>
|
||||
</Section>
|
||||
);
|
||||
@@ -54,24 +92,63 @@ export default function ArtifactsTab({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Webapp Artifact List */}
|
||||
<div className="flex-1 overflow-auto overlay-scrollbar">
|
||||
<div className="divide-y divide-border-01">
|
||||
{webappArtifacts.map((artifact) => {
|
||||
{/* Webapp Artifacts */}
|
||||
{webappArtifacts.map((artifact) => (
|
||||
<div
|
||||
key={artifact.id}
|
||||
className="flex items-center gap-3 p-3 hover:bg-background-tint-01 transition-colors"
|
||||
>
|
||||
<SvgGlobe size={24} className="stroke-text-02 flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<Text secondaryBody text04 className="truncate">
|
||||
{artifact.name}
|
||||
</Text>
|
||||
<Text secondaryBody text02>
|
||||
Next.js Application
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
tertiary
|
||||
action
|
||||
leftIcon={SvgDownloadCloud}
|
||||
onClick={handleWebappDownload}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Output Files & Folders */}
|
||||
{outputEntries.map((entry) => {
|
||||
const FileIcon = entry.is_directory
|
||||
? SvgFolder
|
||||
: getFileIcon(entry.name);
|
||||
return (
|
||||
<div
|
||||
key={artifact.id}
|
||||
key={entry.path}
|
||||
className="flex items-center gap-3 p-3 hover:bg-background-tint-01 transition-colors"
|
||||
>
|
||||
<SvgGlobe size={24} className="stroke-text-02 flex-shrink-0" />
|
||||
<FileIcon size={24} className="stroke-text-02 flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<Text secondaryBody text04 className="truncate">
|
||||
{artifact.name}
|
||||
</Text>
|
||||
<Text secondaryBody text02>
|
||||
Next.js Application
|
||||
{entry.name}
|
||||
</Text>
|
||||
{entry.is_directory ? (
|
||||
<Text secondaryBody text02>
|
||||
Folder
|
||||
</Text>
|
||||
) : entry.size !== null ? (
|
||||
<Text secondaryBody text02>
|
||||
{formatFileSize(entry.size)}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -79,7 +156,9 @@ export default function ArtifactsTab({
|
||||
tertiary
|
||||
action
|
||||
leftIcon={SvgDownloadCloud}
|
||||
onClick={handleDownload}
|
||||
onClick={() =>
|
||||
handleOutputDownload(entry.path, entry.is_directory)
|
||||
}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
@@ -92,3 +171,9 @@ export default function ArtifactsTab({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
@@ -424,6 +424,38 @@ export async function fetchDirectoryListing(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download for a single file from the sandbox.
|
||||
*/
|
||||
export function downloadArtifactFile(sessionId: string, path: string): void {
|
||||
const encodedPath = path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
const link = document.createElement("a");
|
||||
link.href = `${API_BASE}/sessions/${sessionId}/artifacts/${encodedPath}`;
|
||||
link.download = path.split("/").pop() || path;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download for a directory as a zip file.
|
||||
*/
|
||||
export function downloadDirectory(sessionId: string, path: string): void {
|
||||
const encodedPath = path
|
||||
.split("/")
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join("/");
|
||||
const link = document.createElement("a");
|
||||
link.href = `${API_BASE}/sessions/${sessionId}/download-directory/${encodedPath}`;
|
||||
link.download = "";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export interface FileContentResponse {
|
||||
content: string; // For text files: text content. For images: data URL (base64-encoded)
|
||||
mimeType: string;
|
||||
|
||||
Reference in New Issue
Block a user