memos/server/router/api/v1/attachment_upload_test.go
boojack b3e67399b8 feat(attachment): upload files in resumable chunks
Add UploadAttachment, a unary RPC that accepts a file in bounded chunks
and streams it to a temp file instead of holding the whole blob in one
request body and in memory. A call carrying a spec starts an upload and
returns an opaque upload_id; later calls carry that id with
write_offset,
data, and finish_write, following the Cloud Storage WriteObject shape.
Retrying the last chunk after a lost response is idempotent, uploads are
bound to their owner, and they expire after 30 minutes of inactivity.

CreateAttachment and the chunked finalize now share one processing
pipeline for motion-photo detection, EXIF stripping, and storage. The
motion-photo detector works over an io.ReaderAt so large JPEGs are never
loaded whole, and the request body cap is a single per-procedure lookup.

The web editor uploads through the new RPC in 2 MiB chunks with bounded
concurrency under the server's per-user limit.
2026-09-08 21:07:06 +08:00

357 lines
15 KiB
Go

package v1
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"github.com/usememos/memos/internal/testutil"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store"
)
func newUploadTestService(t *testing.T) (*APIV1Service, context.Context) {
t.Helper()
svc := newIntegrationService(t)
t.Cleanup(svc.CloseAttachmentUploads)
user := createSpaceTestUser(context.Background(), t, svc, "uploader", store.RoleUser)
return svc, userCtx(context.Background(), user.ID)
}
func uploadSpec(filename string, size int64) *v1pb.UploadAttachmentRequest_Spec {
return &v1pb.UploadAttachmentRequest_Spec{Spec: &v1pb.UploadAttachmentSpec{Attachment: &v1pb.Attachment{Filename: filename}, TotalSize: size}}
}
func uploadID(id string) *v1pb.UploadAttachmentRequest_UploadId {
return &v1pb.UploadAttachmentRequest_UploadId{UploadId: id}
}
func startTestUpload(ctx context.Context, t *testing.T, svc *APIV1Service, size int64) string {
t.Helper()
spec := uploadSpec("file.bin", size)
spec.Spec.Attachment.Type = "application/octet-stream"
response, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: spec})
require.NoError(t, err)
require.NotEmpty(t, response.UploadId)
require.EqualValues(t, attachmentUploadChunkSize, response.MaxChunkSize)
require.Zero(t, response.CommittedSize)
require.Nil(t, response.Attachment)
return response.UploadId
}
func TestUploadAttachmentChunksAndRetries(t *testing.T) {
svc, ctx := newUploadTestService(t)
id := startTestUpload(ctx, t, svc, 6)
first := &v1pb.UploadAttachmentRequest{Upload: uploadID(id), Data: []byte("abc")}
for range 2 {
response, err := svc.UploadAttachment(ctx, first)
require.NoError(t, err)
require.Equal(t, id, response.UploadId)
require.EqualValues(t, 3, response.CommittedSize)
}
for _, request := range []*v1pb.UploadAttachmentRequest{
{Upload: uploadID(id), Data: []byte("xyz")},
{Upload: uploadID(id), WriteOffset: 4, Data: []byte("d")},
{Upload: uploadID(id), WriteOffset: -1, Data: []byte("d")},
} {
_, err := svc.UploadAttachment(ctx, request)
require.Equal(t, codes.OutOfRange, status.Code(err))
}
progress, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(id), WriteOffset: -1})
require.NoError(t, err)
require.EqualValues(t, 3, progress.CommittedSize)
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(id), WriteOffset: 3, FinishWrite: true})
require.Equal(t, codes.FailedPrecondition, status.Code(err))
last := &v1pb.UploadAttachmentRequest{Upload: uploadID(id), WriteOffset: 3, Data: []byte("def"), FinishWrite: true}
result, err := svc.UploadAttachment(ctx, last)
require.NoError(t, err)
require.EqualValues(t, 6, result.Attachment.Size)
for _, request := range []*v1pb.UploadAttachmentRequest{last, {Upload: uploadID(id)}, {Upload: uploadID(id), WriteOffset: 6, FinishWrite: true}} {
repeated, err := svc.UploadAttachment(ctx, request)
require.NoError(t, err)
require.Equal(t, result.Attachment.Name, repeated.Attachment.Name)
}
rows, err := svc.Store.ListAttachments(ctx, &store.FindAttachment{})
require.NoError(t, err)
require.Len(t, rows, 1)
content, err := os.ReadFile(rows[0].Reference)
require.NoError(t, err)
require.Equal(t, "abcdef", string(content))
require.NoFileExists(t, svc.attachmentUploads.entries[id].path)
_, err = svc.DeleteAttachment(ctx, &v1pb.DeleteAttachmentRequest{Name: result.Attachment.Name})
require.NoError(t, err)
_, err = svc.UploadAttachment(ctx, last)
require.Equal(t, codes.NotFound, status.Code(err))
}
func TestUploadAttachmentSingleCall(t *testing.T) {
svc, ctx := newUploadTestService(t)
spec := uploadSpec("note.txt", 3)
spec.Spec.AttachmentId = "chosen-id"
result, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: spec, Data: []byte("abc"), FinishWrite: true})
require.NoError(t, err)
require.Equal(t, "attachments/chosen-id", result.Attachment.Name)
require.EqualValues(t, 3, result.Attachment.Size)
require.EqualValues(t, 3, result.CommittedSize)
empty, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadSpec("empty.txt", 0), FinishWrite: true})
require.NoError(t, err)
require.Zero(t, empty.Attachment.Size)
// A spec call may carry the first chunk without finishing.
partial, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadSpec("two.bin", 4), Data: []byte("ab")})
require.NoError(t, err)
require.Nil(t, partial.Attachment)
require.EqualValues(t, 2, partial.CommittedSize)
done, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(partial.UploadId), WriteOffset: 2, Data: []byte("cd"), FinishWrite: true})
require.NoError(t, err)
require.EqualValues(t, 4, done.Attachment.Size)
rows, err := svc.Store.ListAttachments(ctx, &store.FindAttachment{})
require.NoError(t, err)
require.Len(t, rows, 3)
}
func TestUploadAttachmentValidation(t *testing.T) {
svc, ctx := newUploadTestService(t)
_, err := svc.UploadAttachment(context.Background(), &v1pb.UploadAttachmentRequest{})
require.Equal(t, codes.Unauthenticated, status.Code(err))
badID := uploadSpec("file", 0)
badID.Spec.AttachmentId = "bad id!"
withContent := uploadSpec("file", 0)
withContent.Spec.Attachment.Content = []byte("not metadata")
for _, request := range []*v1pb.UploadAttachmentRequest{
{},
{Upload: &v1pb.UploadAttachmentRequest_Spec{Spec: &v1pb.UploadAttachmentSpec{}}},
{Upload: uploadSpec("file", -1)},
{Upload: uploadSpec("../file", 0)},
{Upload: withContent},
{Upload: badID},
{Upload: uploadSpec(strings.Repeat("x", attachmentUploadMetadataLimit+1), 0)},
{Upload: uploadSpec("file", 3), Data: []byte("four")},
} {
_, err := svc.UploadAttachment(ctx, request)
require.Equal(t, codes.InvalidArgument, status.Code(err), request.String())
}
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadSpec("file", 3), WriteOffset: 1, Data: []byte("abc")})
require.Equal(t, codes.OutOfRange, status.Code(err))
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadSpec("file", 1<<60)})
require.Equal(t, codes.ResourceExhausted, status.Code(err))
require.Empty(t, svc.attachmentUploads.entries, "rejected spec calls must not register uploads")
id := startTestUpload(ctx, t, svc, 3)
other := createSpaceTestUser(context.Background(), t, svc, "other-uploader", store.RoleUser)
_, err = svc.UploadAttachment(userCtx(context.Background(), other.ID), &v1pb.UploadAttachmentRequest{Upload: uploadID(id)})
require.Equal(t, codes.NotFound, status.Code(err))
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID("unknown")})
require.Equal(t, codes.NotFound, status.Code(err))
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(id), Data: make([]byte, attachmentUploadChunkSize+1)})
require.Equal(t, codes.ResourceExhausted, status.Code(err))
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(id), Data: []byte("four")})
require.Equal(t, codes.InvalidArgument, status.Code(err))
canceled, cancel := context.WithCancel(ctx)
cancel()
_, err = svc.UploadAttachment(canceled, &v1pb.UploadAttachmentRequest{Upload: uploadID(id), Data: []byte("abc")})
require.Error(t, err)
progress, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(id)})
require.NoError(t, err)
require.Zero(t, progress.CommittedSize)
}
func TestUploadAttachmentEmptyAndConcurrentFinish(t *testing.T) {
svc, ctx := newUploadTestService(t)
id := startTestUpload(ctx, t, svc, 0)
var wg sync.WaitGroup
results := make(chan *v1pb.UploadAttachmentResponse, 8)
errs := make(chan error, 8)
for range 8 {
wg.Go(func() {
response, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(id), FinishWrite: true})
results <- response
errs <- err
})
}
wg.Wait()
close(results)
close(errs)
for err := range errs {
require.NoError(t, err)
}
name := ""
for response := range results {
if name == "" {
name = response.Attachment.Name
}
require.Equal(t, name, response.Attachment.Name)
require.Zero(t, response.Attachment.Size)
}
rows, err := svc.Store.ListAttachments(ctx, &store.FindAttachment{})
require.NoError(t, err)
require.Len(t, rows, 1)
}
func TestUploadAttachmentFinalizationRecovery(t *testing.T) {
svc, ctx := newUploadTestService(t)
id := startTestUpload(ctx, t, svc, 3)
last := &v1pb.UploadAttachmentRequest{Upload: uploadID(id), Data: []byte("abc"), FinishWrite: true}
_, err := svc.UploadAttachment(store.WithCreateAttachmentPostCommitFailpoint(ctx), last)
require.Equal(t, codes.Internal, status.Code(err))
result, err := svc.UploadAttachment(ctx, last)
require.NoError(t, err)
require.NotNil(t, result.Attachment)
rows, err := svc.Store.ListAttachments(ctx, &store.FindAttachment{})
require.NoError(t, err)
require.Len(t, rows, 1)
content, err := os.ReadFile(rows[0].Reference)
require.NoError(t, err)
require.Equal(t, "abc", string(content))
}
func TestUploadAttachmentRechecksMemoAndSize(t *testing.T) {
svc, ctx := newUploadTestService(t)
memo, err := svc.CreateMemo(ctx, &v1pb.CreateMemoRequest{Memo: &v1pb.Memo{Content: "upload target"}})
require.NoError(t, err)
spec := uploadSpec("file.txt", 3)
spec.Spec.Attachment.Memo = &memo.Name
initial, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: spec})
require.NoError(t, err)
_, err = svc.DeleteMemo(ctx, &v1pb.DeleteMemoRequest{Name: memo.Name, Force: true})
require.NoError(t, err)
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(initial.UploadId), Data: []byte("abc"), FinishWrite: true})
require.Equal(t, codes.NotFound, status.Code(err))
id := startTestUpload(ctx, t, svc, 2*MebiByte)
_, err = svc.Store.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{Key: storepb.InstanceSettingKey_STORAGE,
Value: &storepb.InstanceSetting_StorageSetting{StorageSetting: &storepb.InstanceStorageSetting{UploadSizeLimitMb: 1}},
})
require.NoError(t, err)
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(id), Data: make([]byte, 2*MebiByte), FinishWrite: true})
require.Equal(t, codes.ResourceExhausted, status.Code(err))
rows, err := svc.Store.ListAttachments(ctx, &store.FindAttachment{})
require.NoError(t, err)
require.Empty(t, rows)
}
func TestUploadAttachmentExpiryAndLimits(t *testing.T) {
svc, ctx := newUploadTestService(t)
ids := make([]string, attachmentUploadMaxActivePerUser)
for i := range ids {
ids[i] = startTestUpload(ctx, t, svc, 0)
}
_, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadSpec("too-many", 0)})
require.Equal(t, codes.ResourceExhausted, status.Code(err))
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(ids[0]), FinishWrite: true})
require.NoError(t, err)
startTestUpload(ctx, t, svc, 0) // Completed uploads do not consume an active slot.
upload := svc.attachmentUploads.entries[ids[1]]
upload.mu.Lock()
upload.expireTime = time.Now().Add(-time.Second)
upload.mu.Unlock()
_, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(ids[1])})
require.Equal(t, codes.NotFound, status.Code(err))
orphan := filepath.Join(svc.Profile.Data, attachmentUploadTempPrefix+"orphan")
recent := filepath.Join(svc.Profile.Data, attachmentUploadTempPrefix+"recent")
unrelated := filepath.Join(svc.Profile.Data, "keep.txt")
for _, path := range []string{orphan, recent, unrelated} {
require.NoError(t, os.WriteFile(path, []byte("x"), 0600))
}
old := time.Now().Add(-2 * attachmentUploadTTL)
require.NoError(t, os.Chtimes(orphan, old, old))
require.NoError(t, os.Chtimes(unrelated, old, old))
svc.attachmentUploads.mu.Lock()
svc.attachmentUploads.sweepLocked(time.Now(), 0)
svc.attachmentUploads.removeOrphansLocked(svc.Profile.Data, time.Now())
svc.attachmentUploads.mu.Unlock()
require.NoFileExists(t, upload.path)
require.NoFileExists(t, orphan)
require.FileExists(t, recent)
require.FileExists(t, unrelated)
svc.CloseAttachmentUploads()
require.Empty(t, svc.attachmentUploads.entries)
}
func TestUploadAttachmentAboveLegacyRequestLimit(t *testing.T) {
svc, ctx := newUploadTestService(t)
const size = 300 * MebiByte
_, err := svc.Store.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{Key: storepb.InstanceSettingKey_STORAGE,
Value: &storepb.InstanceSetting_StorageSetting{StorageSetting: &storepb.InstanceStorageSetting{UploadSizeLimitMb: 400}},
})
require.NoError(t, err)
id := startTestUpload(ctx, t, svc, size)
chunk := bytes.Repeat([]byte{0x5a}, attachmentUploadChunkSize)
var response *v1pb.UploadAttachmentResponse
for offset := int64(0); offset < size; offset += int64(len(chunk)) {
response, err = svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{
Upload: uploadID(id), WriteOffset: offset, Data: chunk, FinishWrite: offset+int64(len(chunk)) == size,
})
require.NoError(t, err)
}
require.EqualValues(t, size, response.Attachment.Size)
rows, err := svc.Store.ListAttachments(ctx, &store.FindAttachment{})
require.NoError(t, err)
require.Len(t, rows, 1)
file, err := os.Open(rows[0].Reference)
require.NoError(t, err)
defer file.Close()
info, err := file.Stat()
require.NoError(t, err)
require.EqualValues(t, size, info.Size())
_, err = file.Seek(-int64(len(chunk)), io.SeekEnd)
require.NoError(t, err)
tail := make([]byte, len(chunk))
_, err = io.ReadFull(file, tail)
require.NoError(t, err)
require.Equal(t, chunk, tail)
}
func TestUploadAttachmentMediaProcessing(t *testing.T) {
for _, tc := range []struct {
name string
content []byte
motion bool
}{
{name: "JPEG", content: testutil.BuildJPEG(20, 10)},
{name: "motion photo", content: testutil.BuildMotionPhotoJPEG(), motion: true},
} {
t.Run(tc.name, func(t *testing.T) {
svc, ctx := newUploadTestService(t)
metadata := &v1pb.Attachment{Filename: "image.unknown", MediaMetadata: &v1pb.MediaMetadata{Width: proto.Int32(20), Height: proto.Int32(10)}}
spec := &v1pb.UploadAttachmentRequest_Spec{Spec: &v1pb.UploadAttachmentSpec{Attachment: metadata, TotalSize: int64(len(tc.content))}}
initial, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: spec})
require.NoError(t, err)
result, err := svc.UploadAttachment(ctx, &v1pb.UploadAttachmentRequest{Upload: uploadID(initial.UploadId), Data: tc.content, FinishWrite: true})
require.NoError(t, err)
require.Equal(t, "image/jpeg", result.Attachment.Type)
require.True(t, proto.Equal(metadata.MediaMetadata, result.Attachment.MediaMetadata))
uid, err := ExtractAttachmentUIDFromName(result.Attachment.Name)
require.NoError(t, err)
row, err := svc.Store.GetAttachment(ctx, &store.FindAttachment{UID: &uid})
require.NoError(t, err)
content, err := os.ReadFile(row.Reference)
require.NoError(t, err)
if tc.motion {
require.Equal(t, tc.content, content)
require.True(t, result.Attachment.MotionMedia.HasEmbeddedVideo)
} else {
stripped, err := stripImageExif(bytes.NewReader(tc.content), "image/jpeg")
require.NoError(t, err)
require.Equal(t, stripped, content)
}
})
}
}