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.
194 lines
5.2 KiB
Go
194 lines
5.2 KiB
Go
package v1
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
|
)
|
|
|
|
const (
|
|
attachmentUploadChunkSize = 2 << 20
|
|
attachmentUploadRequestLimit = 4 << 20
|
|
attachmentUploadMetadataLimit = 64 << 10
|
|
attachmentUploadTTL = 30 * time.Minute
|
|
attachmentUploadMaxSessions = 1024
|
|
attachmentUploadMaxActive = 128
|
|
attachmentUploadMaxActivePerUser = 8
|
|
attachmentUploadTempPrefix = ".memos-rpc-upload-"
|
|
attachmentUploadProcedure = "/memos.api.v1.AttachmentService/UploadAttachment"
|
|
)
|
|
|
|
// Upload state is private to the process; only Attachment is an API resource.
|
|
// Each upload holds metadata and the last chunk's digest, never its contents.
|
|
type attachmentUpload struct {
|
|
mu sync.Mutex
|
|
ownerID int32
|
|
metadata *v1pb.Attachment
|
|
uid string
|
|
path string
|
|
totalSize int64
|
|
committedSize int64
|
|
// expireTime is zeroed to revoke an upload before its TTL elapses.
|
|
expireTime time.Time
|
|
// lastDigest identifies the most recently accepted chunk so a client can
|
|
// safely resend it after losing the response.
|
|
lastDigest [sha256.Size]byte
|
|
finalizeAttempted bool
|
|
complete bool
|
|
}
|
|
|
|
type attachmentUploads struct {
|
|
mu sync.Mutex
|
|
entries map[string]*attachmentUpload
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
closed bool
|
|
}
|
|
|
|
func (m *attachmentUploads) startLocked(dir string) {
|
|
if m.entries != nil {
|
|
return
|
|
}
|
|
m.entries = make(map[string]*attachmentUpload)
|
|
m.stop = make(chan struct{})
|
|
m.done = make(chan struct{})
|
|
go func() {
|
|
defer close(m.done)
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-m.stop:
|
|
return
|
|
case now := <-ticker.C:
|
|
m.mu.Lock()
|
|
m.sweepLocked(now, 0)
|
|
m.removeOrphansLocked(dir, now)
|
|
m.mu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// sweepLocked drops expired uploads and counts the unfinished ones, both in
|
|
// total and for ownerID. An upload locked by an in-flight request is
|
|
// unfinished by definition.
|
|
func (m *attachmentUploads) sweepLocked(now time.Time, ownerID int32) (owned, total int) {
|
|
count := func(upload *attachmentUpload) {
|
|
total++
|
|
if upload.ownerID == ownerID {
|
|
owned++
|
|
}
|
|
}
|
|
for id, upload := range m.entries {
|
|
// Never hold the manager lock while waiting for a file write or finalize.
|
|
if !upload.mu.TryLock() {
|
|
count(upload)
|
|
continue
|
|
}
|
|
if !now.Before(upload.expireTime) {
|
|
os.Remove(upload.path)
|
|
delete(m.entries, id)
|
|
} else if !upload.complete {
|
|
count(upload)
|
|
}
|
|
upload.mu.Unlock()
|
|
}
|
|
return owned, total
|
|
}
|
|
|
|
func (m *attachmentUploads) removeOrphansLocked(dir string, now time.Time) {
|
|
tracked := make(map[string]bool, len(m.entries))
|
|
for _, upload := range m.entries {
|
|
tracked[upload.path] = true // path is immutable after insertion.
|
|
}
|
|
paths, _ := filepath.Glob(filepath.Join(dir, attachmentUploadTempPrefix+"*"))
|
|
for _, path := range paths {
|
|
if tracked[path] {
|
|
continue
|
|
}
|
|
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && now.Sub(info.ModTime()) >= attachmentUploadTTL {
|
|
os.Remove(path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *attachmentUploads) create(dir string, upload *attachmentUpload) (string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.closed {
|
|
return "", status.Errorf(codes.Unavailable, "server is shutting down")
|
|
}
|
|
m.startLocked(dir)
|
|
owned, total := m.sweepLocked(time.Now(), upload.ownerID)
|
|
if owned >= attachmentUploadMaxActivePerUser {
|
|
return "", status.Errorf(codes.ResourceExhausted, "too many active uploads")
|
|
}
|
|
if len(m.entries) >= attachmentUploadMaxSessions || total >= attachmentUploadMaxActive {
|
|
return "", status.Errorf(codes.ResourceExhausted, "too many uploads")
|
|
}
|
|
file, err := os.CreateTemp(dir, attachmentUploadTempPrefix+"*")
|
|
if err != nil {
|
|
return "", status.Errorf(codes.Internal, "failed to create upload file: %v", err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
os.Remove(file.Name())
|
|
return "", status.Errorf(codes.Internal, "failed to close upload file: %v", err)
|
|
}
|
|
upload.path = file.Name()
|
|
upload.expireTime = time.Now().Add(attachmentUploadTTL)
|
|
id := rand.Text()
|
|
m.entries[id] = upload
|
|
return id, nil
|
|
}
|
|
|
|
func (m *attachmentUploads) get(id string, ownerID int32) (*attachmentUpload, error) {
|
|
m.mu.Lock()
|
|
upload := m.entries[id]
|
|
closed := m.closed
|
|
m.mu.Unlock()
|
|
if closed {
|
|
return nil, status.Errorf(codes.Unavailable, "server is shutting down")
|
|
}
|
|
if upload == nil || upload.ownerID != ownerID {
|
|
return nil, status.Errorf(codes.NotFound, "upload not found or expired")
|
|
}
|
|
return upload, nil
|
|
}
|
|
|
|
// CloseAttachmentUploads stops expiration work and removes pending upload files.
|
|
// Call it after draining HTTP requests during server shutdown.
|
|
func (s *APIV1Service) CloseAttachmentUploads() {
|
|
m := &s.attachmentUploads
|
|
m.mu.Lock()
|
|
if m.closed {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
m.closed = true
|
|
if m.stop != nil {
|
|
close(m.stop)
|
|
}
|
|
done := m.done
|
|
m.mu.Unlock()
|
|
if done != nil {
|
|
<-done
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for id, upload := range m.entries {
|
|
upload.mu.Lock()
|
|
upload.expireTime = time.Time{}
|
|
os.Remove(upload.path)
|
|
upload.mu.Unlock()
|
|
delete(m.entries, id)
|
|
}
|
|
}
|