mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-11 17:10:40 +02:00
The storage rule's fsync decision was computed by the filer (detectStorageOption -> rule.Fsync) and applied on the filer's own HTTP write path, but was never carried onto the chunk uploads S3 issues: the AssignVolumeResponse had no fsync field, so the s3api client could not learn the decision, and the chunked upload URL was hardcoded without it. Every S3 write to a path with fsync configured went to the volume server as a non-fsync write. Carry the decision through the assign response: - filer.proto: AssignVolumeResponse gains bool fsync, filled from the storage option the assign resolved. - operation.AssignResult gains Fsync, so uploadChunk can append ?fsync=true to the volume server upload URL (single and replica fan-out paths). - The S3 PUT/UploadPart assignFunc, the S3 copy path, the admin file browser upload, and the Iceberg worker assign functions all forward the response field. Adds TestUploadReaderInChunksAppendsFsyncWhenAssigned.
270 lines
8.1 KiB
Go
270 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),
|
|
Fsync: assignResp.Fsync,
|
|
}, 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)
|
|
}
|