e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
204 lines
5.3 KiB
Go
204 lines
5.3 KiB
Go
// Copyright 2025 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package controller
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/util/pathutil"
|
|
"github.com/alibaba/opensandbox/execd/pkg/web/model"
|
|
)
|
|
|
|
// DownloadFile serves a file for download with support for range requests
|
|
// and line-based reading via offset/limit query parameters.
|
|
func (c *FilesystemController) DownloadFile() {
|
|
rec := beginFilesystemMetric("download")
|
|
defer rec.Finish(c.basicController)
|
|
|
|
filePath := c.ctx.Query("path")
|
|
if filePath == "" {
|
|
c.RespondError(
|
|
http.StatusBadRequest,
|
|
model.ErrorCodeMissingQuery,
|
|
"missing query parameter 'path'",
|
|
)
|
|
return
|
|
}
|
|
resolvedFilePath, err := pathutil.ExpandPath(filePath)
|
|
if err != nil {
|
|
c.RespondError(
|
|
http.StatusInternalServerError,
|
|
model.ErrorCodeRuntimeError,
|
|
fmt.Sprintf("error resolving file path: %s. %v", filePath, err),
|
|
)
|
|
return
|
|
}
|
|
|
|
rawOffset := c.ctx.Query("offset")
|
|
rawLimit := c.ctx.Query("limit")
|
|
hasLineParams := rawOffset != "" || rawLimit != ""
|
|
rangeHeader := c.ctx.GetHeader("Range")
|
|
|
|
if hasLineParams && rangeHeader != "" {
|
|
c.RespondError(
|
|
http.StatusBadRequest,
|
|
model.ErrorCodeInvalidRequest,
|
|
"line-based reading (offset/limit) and byte range (Range header) are mutually exclusive",
|
|
)
|
|
return
|
|
}
|
|
|
|
file, err := os.Open(resolvedFilePath)
|
|
if err != nil {
|
|
c.handleFileError(err)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
if hasLineParams {
|
|
c.serveLineRange(file, rawOffset, rawLimit)
|
|
rec.MarkSuccess()
|
|
return
|
|
}
|
|
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
c.RespondError(
|
|
http.StatusInternalServerError,
|
|
model.ErrorCodeRuntimeError,
|
|
fmt.Sprintf("error getting file stat info: %s. %v", resolvedFilePath, err),
|
|
)
|
|
return
|
|
}
|
|
|
|
c.ctx.Header("Content-Type", "application/octet-stream")
|
|
c.ctx.Header("Content-Disposition", formatContentDisposition(filepath.Base(resolvedFilePath)))
|
|
c.ctx.Header("Content-Length", strconv.FormatInt(fileInfo.Size(), 10))
|
|
|
|
if rangeHeader != "" {
|
|
ranges, err := ParseRange(rangeHeader, fileInfo.Size())
|
|
if err != nil {
|
|
c.RespondError(
|
|
http.StatusRequestedRangeNotSatisfiable,
|
|
model.ErrorCodeUnknown,
|
|
)
|
|
return
|
|
}
|
|
if len(ranges) > 0 {
|
|
r := ranges[0]
|
|
c.ctx.Status(http.StatusPartialContent)
|
|
c.ctx.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", r.start, r.start+r.length-1, fileInfo.Size()))
|
|
c.ctx.Header("Content-Length", strconv.FormatInt(r.length, 10))
|
|
|
|
_, _ = file.Seek(r.start, io.SeekStart)
|
|
_, _ = io.CopyN(c.ctx.Writer, file, r.length)
|
|
rec.MarkSuccess()
|
|
return
|
|
}
|
|
}
|
|
|
|
rec.MarkSuccess()
|
|
http.ServeContent(c.ctx.Writer, c.ctx.Request, filepath.Base(resolvedFilePath), fileInfo.ModTime(), file)
|
|
}
|
|
|
|
// serveLineRange reads lines from file starting at a 1-based offset and
|
|
// returns up to limit lines as text/plain.
|
|
func (c *FilesystemController) serveLineRange(file *os.File, rawOffset, rawLimit string) {
|
|
offset := int64(1)
|
|
if rawOffset != "" {
|
|
parsed, err := strconv.ParseInt(rawOffset, 10, 64)
|
|
if err != nil || parsed < 1 {
|
|
c.RespondError(
|
|
http.StatusBadRequest,
|
|
model.ErrorCodeInvalidRequest,
|
|
fmt.Sprintf("invalid query parameter 'offset': %s", rawOffset),
|
|
)
|
|
return
|
|
}
|
|
offset = parsed
|
|
}
|
|
|
|
limit := int64(-1)
|
|
if rawLimit != "" {
|
|
parsed, err := strconv.ParseInt(rawLimit, 10, 64)
|
|
if err != nil || parsed < 1 {
|
|
c.RespondError(
|
|
http.StatusBadRequest,
|
|
model.ErrorCodeInvalidRequest,
|
|
fmt.Sprintf("invalid query parameter 'limit': %s", rawLimit),
|
|
)
|
|
return
|
|
}
|
|
limit = parsed
|
|
}
|
|
|
|
c.ctx.Header("Content-Type", "text/plain; charset=utf-8")
|
|
c.ctx.Status(http.StatusOK)
|
|
|
|
reader := bufio.NewReader(file)
|
|
var lineNum int64
|
|
var written int64
|
|
for {
|
|
line, err := reader.ReadBytes('\n')
|
|
if len(line) > 0 {
|
|
line = bytes.TrimRight(line, "\r\n")
|
|
lineNum++
|
|
if lineNum >= offset {
|
|
if written > 0 {
|
|
_, _ = c.ctx.Writer.Write([]byte("\n"))
|
|
}
|
|
_, _ = c.ctx.Writer.Write(line)
|
|
written++
|
|
if limit >= 0 && written >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatContentDisposition formats the Content-Disposition header value with proper
|
|
// encoding for non-ASCII filenames according to RFC 6266 and RFC 5987.
|
|
func formatContentDisposition(filename string) string {
|
|
// Check if filename contains non-ASCII characters
|
|
needsEncoding := false
|
|
for _, r := range filename {
|
|
if r > 127 {
|
|
needsEncoding = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !needsEncoding {
|
|
return "attachment; filename=\"" + filename + "\""
|
|
}
|
|
|
|
// Use RFC 5987 encoding for non-ASCII filenames
|
|
// Format: attachment; filename="fallback"; filename*=UTF-8''encoded_name
|
|
encodedFilename := url.PathEscape(filename)
|
|
return "attachment; filename=\"" + encodedFilename + "\"; filename*=UTF-8''" + encodedFilename
|
|
}
|