mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-20 22:27:04 +00:00
* admin: move volume-server read JWT helper into dash The Iceberg data preview page needs the same per-fileId read token the file browser uses when streaming chunks from volume servers. Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur * admin: add Iceberg table data preview page The admin UI browses the Iceberg catalog down to table details but not the data itself. Add a Browse Data page per table that walks the selected snapshot's manifests and shows sample rows from its Parquet data files, plus the data file list with per-file preview, a snapshot switcher, and a row limit selector. Rows are read through a ranged ReaderAt over stream-content so only the Parquet footer and needed pages are fetched, with the volume read JWT applied when configured. Iceberg locations resolve into /buckets with traversal guards, and the file parameter must match a manifest-listed data file. Snapshots with delete files get a warning that raw rows are shown. Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur * admin: integration test for Iceberg catalog and data preview pages Starts a weed mini cluster with the admin UI, creates a table bucket, namespace, and tables via the S3 Tables manager, uploads real Parquet files via S3, writes manifests and snapshots with iceberg-go, and asserts on the rendered pages: catalog browsing, table details, current and historical snapshot previews, per-file preview, row limits, unknown snapshot and file errors, and a metadata-less table. Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur * admin: write Iceberg preview chunk reads straight into the caller slice ReadAt wrapped the caller's buffer in a bytes.Buffer, which would silently allocate a fresh backing array and drop bytes if it ever grew. Copy directly into the destination slice and reject negative offsets so the ReaderAt contract holds. Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur * admin: link to snapshot history when the preview switcher truncates The snapshot switcher caps at 25 entries; add a trailing item pointing at the table details page so older snapshots stay reachable. Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur * test: hoist mini cluster context assignment out of the goroutine Set MiniClusterCtx before launching the cluster goroutine and clear it in stop(), so the assignment is not buried in the command loop. Claude-Session: https://claude.ai/code/session_015n3oKLTjnPjcnZtfigNKur
269 lines
8.1 KiB
Go
269 lines
8.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/operation"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
)
|
|
|
|
// Admin upload chunk size, matching s3api so files split into the same fid-sized pieces.
|
|
const adminUploadChunkSize = 8 * 1024 * 1024
|
|
|
|
// File browser handlers backed by the filer gRPC service. They bypass the
|
|
// filer's HTTP listener so the UI keeps working when the filer is started
|
|
// with -disableHttp=true; chunk bytes still flow through the volume server
|
|
// HTTP endpoints (which run on their own ports).
|
|
|
|
// fetchFileContentGrpc reads file content via the filer gRPC service, looking
|
|
// the entry up and then streaming the chunks straight from the volume servers.
|
|
// When maxBytes > 0 the stream is truncated to that many bytes — used by the
|
|
// "is this text?" sniff so unknown-MIME files don't get fully downloaded.
|
|
func (h *FileBrowserHandlers) fetchFileContentGrpc(ctx context.Context, filePath string, maxBytes int) (string, error) {
|
|
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
entry, err := h.lookupEntry(ctx, cleanFilePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if entry.IsDirectory {
|
|
return "", fmt.Errorf("path is a directory")
|
|
}
|
|
|
|
size := int64(filer.FileSize(entry))
|
|
streamSize := size
|
|
if maxBytes > 0 && streamSize > int64(maxBytes) {
|
|
streamSize = int64(maxBytes)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := h.streamEntryContent(ctx, entry, streamSize, &buf); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// downloadFileGrpc streams a file via gRPC + volume server HTTP. The
|
|
// response writer receives the canonical attachment headers and the raw
|
|
// bytes; this replaces the HTTP-to-filer proxy that used to run in
|
|
// DownloadFile.
|
|
func (h *FileBrowserHandlers) downloadFileGrpc(ctx context.Context, filePath string, w http.ResponseWriter, inline bool) error {
|
|
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
entry, err := h.lookupEntry(ctx, cleanFilePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.IsDirectory {
|
|
return fmt.Errorf("path is a directory")
|
|
}
|
|
|
|
size := int64(filer.FileSize(entry))
|
|
|
|
fileName := path.Base(cleanFilePath)
|
|
|
|
// Resolve mime like the viewer does so Content-Type and the inline check agree.
|
|
contentType := dash.ResolveEntryMime(entry)
|
|
|
|
// Only inline images and PDFs; serve the rest as attachments so a hostile upload
|
|
// (HTML, SVG) can't run as same-origin script. nosniff locks the declared type.
|
|
disposition := "attachment"
|
|
if inline && (strings.HasPrefix(contentType, "image/") || contentType == "application/pdf") {
|
|
disposition = "inline"
|
|
}
|
|
w.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": fileName}))
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
return h.streamEntryContent(ctx, entry, size, w)
|
|
}
|
|
|
|
// uploadFileGrpc streams the upload to volumes in 8 MiB chunks via the shared
|
|
// chunked-upload helper, then registers the entry over the filer gRPC service.
|
|
// Content always lands in volumes, never inlined on the entry.
|
|
func (h *FileBrowserHandlers) uploadFileGrpc(ctx context.Context, filePath string, fileName string, mimeType string, reader io.Reader) error {
|
|
cleanFilePath, err := h.validateAndCleanFilePath(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dir := path.Dir(cleanFilePath)
|
|
if dir == "." {
|
|
dir = "/"
|
|
}
|
|
entryName := path.Base(cleanFilePath)
|
|
if mimeType == "" {
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
|
|
assignFunc := func(ctx context.Context, count int, expectedDataSize uint64) (*operation.VolumeAssignRequest, *operation.AssignResult, error) {
|
|
var assignResp *filer_pb.AssignVolumeResponse
|
|
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
|
resp, assignErr := client.AssignVolume(ctx, &filer_pb.AssignVolumeRequest{
|
|
Count: int32(count),
|
|
Path: cleanFilePath,
|
|
ExpectedDataSize: expectedDataSize,
|
|
})
|
|
if assignErr != nil {
|
|
return assignErr
|
|
}
|
|
if resp.Error != "" {
|
|
return fmt.Errorf("%s", resp.Error)
|
|
}
|
|
assignResp = resp
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if assignResp.Location == nil || assignResp.FileId == "" {
|
|
return nil, nil, fmt.Errorf("assign volume returned empty location")
|
|
}
|
|
return nil, &operation.AssignResult{
|
|
Fid: assignResp.FileId,
|
|
Url: assignResp.Location.Url,
|
|
PublicUrl: assignResp.Location.PublicUrl,
|
|
Count: uint64(count),
|
|
Auth: security.EncodedJwt(assignResp.Auth),
|
|
}, nil
|
|
}
|
|
|
|
chunkResult, err := operation.UploadReaderInChunks(ctx, reader, &operation.ChunkedUploadOption{
|
|
ChunkSize: adminUploadChunkSize,
|
|
MimeType: mimeType,
|
|
AssignFunc: assignFunc,
|
|
})
|
|
if err != nil {
|
|
// Partial chunks come back even on error so we can clean them up rather
|
|
// than leaving orphaned data on volume servers.
|
|
if chunkResult != nil && len(chunkResult.FileChunks) > 0 {
|
|
h.deleteOrphanedChunks(chunkResult.FileChunks)
|
|
}
|
|
return fmt.Errorf("upload: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
entry := &filer_pb.Entry{
|
|
Name: entryName,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
FileSize: uint64(chunkResult.TotalSize),
|
|
Mtime: now.Unix(),
|
|
Crtime: now.Unix(),
|
|
FileMode: 0644,
|
|
Mime: mimeType,
|
|
},
|
|
}
|
|
entry.Chunks = chunkResult.FileChunks
|
|
|
|
err = h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
|
_, createErr := client.CreateEntry(ctx, &filer_pb.CreateEntryRequest{
|
|
Directory: dir,
|
|
Entry: entry,
|
|
})
|
|
return createErr
|
|
})
|
|
if err != nil {
|
|
if len(chunkResult.FileChunks) > 0 {
|
|
h.deleteOrphanedChunks(chunkResult.FileChunks)
|
|
}
|
|
return fmt.Errorf("create entry: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteOrphanedChunks best-effort removes the chunk fids when an upload
|
|
// fails partway through. Errors are logged; we can't surface them past the
|
|
// caller's primary failure.
|
|
func (h *FileBrowserHandlers) deleteOrphanedChunks(chunks []*filer_pb.FileChunk) {
|
|
fileIds := make([]string, 0, len(chunks))
|
|
for _, c := range chunks {
|
|
if fid := c.GetFileIdString(); fid != "" {
|
|
fileIds = append(fileIds, fid)
|
|
}
|
|
}
|
|
if len(fileIds) == 0 {
|
|
return
|
|
}
|
|
master := h.adminServer.GetMasterClient()
|
|
results := operation.DeleteFileIds(master.GetMaster, false, h.adminServer.GetGrpcDialOption(), fileIds)
|
|
for _, r := range results {
|
|
if r.Error != "" {
|
|
glog.Warningf("admin file browser: orphan chunk %s cleanup: %s", r.FileId, r.Error)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *FileBrowserHandlers) lookupEntry(ctx context.Context, cleanFilePath string) (*filer_pb.Entry, error) {
|
|
dir := path.Dir(cleanFilePath)
|
|
if dir == "." {
|
|
dir = "/"
|
|
}
|
|
name := path.Base(cleanFilePath)
|
|
var entry *filer_pb.Entry
|
|
err := h.adminServer.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
|
resp, lookupErr := client.LookupDirectoryEntry(ctx, &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: dir,
|
|
Name: name,
|
|
})
|
|
if lookupErr != nil {
|
|
return lookupErr
|
|
}
|
|
if resp.Entry == nil {
|
|
return fmt.Errorf("not found")
|
|
}
|
|
entry = resp.Entry
|
|
return nil
|
|
})
|
|
return entry, err
|
|
}
|
|
|
|
func (h *FileBrowserHandlers) streamEntryContent(ctx context.Context, entry *filer_pb.Entry, size int64, w io.Writer) error {
|
|
if size == 0 {
|
|
// Inline content (small files stored directly on the entry) skip the
|
|
// chunk pipeline entirely.
|
|
if len(entry.Content) > 0 {
|
|
_, err := w.Write(entry.Content)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if len(entry.Content) > 0 && len(entry.GetChunks()) == 0 {
|
|
_, err := w.Write(entry.Content)
|
|
return err
|
|
}
|
|
|
|
streamFn, err := filer.PrepareStreamContentWithThrottler(
|
|
ctx,
|
|
h.adminServer.GetMasterClient(),
|
|
dash.VolumeServerReadJwt,
|
|
entry.GetChunks(),
|
|
0,
|
|
size,
|
|
0,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("prepare stream: %w", err)
|
|
}
|
|
return streamFn(w)
|
|
}
|