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.
111 lines
4.4 KiB
Go
111 lines
4.4 KiB
Go
package v1
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"connectrpc.com/connect"
|
|
"github.com/labstack/echo/v5"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
|
"github.com/usememos/memos/proto/gen/api/v1/apiv1connect"
|
|
"github.com/usememos/memos/server/auth"
|
|
)
|
|
|
|
type uploadCountingReader struct {
|
|
reader io.Reader
|
|
read int
|
|
}
|
|
|
|
func (r *uploadCountingReader) Read(p []byte) (int, error) {
|
|
n, err := r.reader.Read(p)
|
|
r.read += n
|
|
return n, err
|
|
}
|
|
|
|
func TestUploadAttachmentTransports(t *testing.T) {
|
|
svc, ctx := newUploadTestService(t)
|
|
user, err := svc.fetchCurrentUser(ctx)
|
|
require.NoError(t, err)
|
|
token, _, err := auth.GenerateAccessTokenV2(user.ID, user.Username, string(user.Role), string(user.RowStatus), []byte(svc.Secret))
|
|
require.NoError(t, err)
|
|
e := echo.New()
|
|
require.NoError(t, svc.RegisterGateway(context.Background(), e))
|
|
|
|
for _, path := range []string{"/api/v1/attachments:upload", attachmentUploadProcedure} {
|
|
t.Run(path, func(t *testing.T) {
|
|
call := func(message proto.Message, authenticated bool) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
data, err := protojson.Marshal(message)
|
|
require.NoError(t, err)
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(data))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if authenticated {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
initial := &v1pb.UploadAttachmentRequest{Upload: uploadSpec("file.txt", 3)}
|
|
require.Equal(t, http.StatusUnauthorized, call(initial, false).Code)
|
|
rec := call(initial, true)
|
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
|
response := &v1pb.UploadAttachmentResponse{}
|
|
require.NoError(t, protojson.Unmarshal(rec.Body.Bytes(), response))
|
|
rec = call(&v1pb.UploadAttachmentRequest{Upload: uploadID(response.UploadId), Data: []byte("abc"), FinishWrite: true}, true)
|
|
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
|
require.NoError(t, protojson.Unmarshal(rec.Body.Bytes(), response))
|
|
require.NotNil(t, response.Attachment)
|
|
|
|
oversized, err := protojson.Marshal(&v1pb.UploadAttachmentRequest{Data: make([]byte, attachmentUploadRequestLimit)})
|
|
require.NoError(t, err)
|
|
reader := &uploadCountingReader{reader: bytes.NewReader(oversized)}
|
|
req := httptest.NewRequest(http.MethodPost, path, reader)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec = httptest.NewRecorder()
|
|
e.ServeHTTP(rec, req)
|
|
require.GreaterOrEqual(t, rec.Code, 400)
|
|
require.LessOrEqual(t, reader.read, attachmentUploadRequestLimit+1, "body must be bounded before decoding")
|
|
})
|
|
}
|
|
|
|
t.Run("Connect protobuf and compressed message limit", func(t *testing.T) {
|
|
server := httptest.NewTestServer(t, e)
|
|
client := apiv1connect.NewAttachmentServiceClient(server.Client(), server.URL)
|
|
initial := connect.NewRequest(&v1pb.UploadAttachmentRequest{Upload: uploadSpec("binary.bin", attachmentUploadChunkSize)})
|
|
initial.Header().Set("Authorization", "Bearer "+token)
|
|
response, err := client.UploadAttachment(context.Background(), initial)
|
|
require.NoError(t, err)
|
|
last := connect.NewRequest(&v1pb.UploadAttachmentRequest{Upload: uploadID(response.Msg.UploadId), Data: make([]byte, attachmentUploadChunkSize), FinishWrite: true})
|
|
last.Header().Set("Authorization", "Bearer "+token)
|
|
finished, err := client.UploadAttachment(context.Background(), last)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, attachmentUploadChunkSize, finished.Msg.Attachment.Size)
|
|
|
|
data, err := proto.Marshal(&v1pb.UploadAttachmentRequest{Data: make([]byte, attachmentUploadRequestLimit+1)})
|
|
require.NoError(t, err)
|
|
var compressed bytes.Buffer
|
|
writer := gzip.NewWriter(&compressed)
|
|
_, err = writer.Write(data)
|
|
require.NoError(t, err)
|
|
require.NoError(t, writer.Close())
|
|
req := httptest.NewRequest(http.MethodPost, attachmentUploadProcedure, &compressed)
|
|
req.Header.Set("Content-Type", "application/proto")
|
|
req.Header.Set("Content-Encoding", "gzip")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
e.ServeHTTP(rec, req)
|
|
require.Equal(t, http.StatusTooManyRequests, rec.Code, rec.Body.String())
|
|
require.Contains(t, rec.Body.String(), "resource_exhausted")
|
|
})
|
|
}
|