2026-06-06 00:05:04 -04:00
|
|
|
package uploads
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"math"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
defaultSessionTTL = 2 * time.Hour
|
|
|
|
|
defaultMaxSize = 256 << 20
|
|
|
|
|
defaultChunkSize = 8 << 20
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
ErrAlreadyCompleted = errors.New("upload session is already completed")
|
2026-07-27 12:07:37 -04:00
|
|
|
ErrChunkBusy = errors.New("upload chunk already in progress")
|
2026-06-06 00:05:04 -04:00
|
|
|
ErrExpired = errors.New("upload session expired")
|
|
|
|
|
ErrIncomplete = errors.New("upload session is incomplete")
|
|
|
|
|
ErrInvalidChunk = errors.New("invalid upload chunk")
|
|
|
|
|
ErrInvalidRequest = errors.New("invalid upload session request")
|
|
|
|
|
ErrNotFound = errors.New("upload session not found")
|
|
|
|
|
ErrTooLarge = errors.New("upload exceeds maximum size")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type ManagerOptions struct {
|
|
|
|
|
RootDir string
|
|
|
|
|
TTL time.Duration
|
|
|
|
|
MaxSize int64
|
|
|
|
|
MaxChunkSize int64
|
|
|
|
|
Now func() time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CreateRequest struct {
|
|
|
|
|
Filename string
|
|
|
|
|
SizeBytes int64
|
|
|
|
|
ChunkSize int64
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type SessionInfo struct {
|
|
|
|
|
ID string
|
|
|
|
|
Filename string
|
|
|
|
|
SizeBytes int64
|
|
|
|
|
ChunkSize int64
|
|
|
|
|
TotalChunks int
|
|
|
|
|
ReceivedChunks int
|
|
|
|
|
ReceivedBytes int64
|
|
|
|
|
Complete bool
|
|
|
|
|
ExpiresAt time.Time
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CompletedUpload struct {
|
|
|
|
|
ID string
|
|
|
|
|
Filename string
|
|
|
|
|
SizeBytes int64
|
|
|
|
|
Path string
|
|
|
|
|
Cleanup func()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Manager struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
rootDir string
|
|
|
|
|
ttl time.Duration
|
|
|
|
|
maxSize int64
|
|
|
|
|
maxChunkSize int64
|
|
|
|
|
now func() time.Time
|
|
|
|
|
sessions map[string]*session
|
2026-07-27 12:07:37 -04:00
|
|
|
// detached holds sessions removed from `sessions` (canceled or expired)
|
|
|
|
|
// whose chunk writers are still draining a request body into the spool.
|
|
|
|
|
// They keep holding real disk and a connection until the writer finishes
|
|
|
|
|
// or hits the read deadline, so callers enforcing admission caps must
|
|
|
|
|
// count them (DetachedWriterSessions) or a cancel-and-recreate loop could
|
|
|
|
|
// stack unbounded live writers behind a fixed session cap.
|
|
|
|
|
detached map[*session]struct{}
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type session struct {
|
|
|
|
|
id string
|
|
|
|
|
filename string
|
|
|
|
|
sizeBytes int64
|
|
|
|
|
chunkSize int64
|
|
|
|
|
totalChunks int
|
|
|
|
|
received []bool
|
|
|
|
|
receivedChunks int
|
|
|
|
|
receivedBytes int64
|
|
|
|
|
path string
|
|
|
|
|
dir string
|
|
|
|
|
expiresAt time.Time
|
|
|
|
|
completed bool
|
2026-07-27 12:07:37 -04:00
|
|
|
// writing marks chunk indexes with a body copy in flight. Body I/O happens
|
|
|
|
|
// outside the manager mutex (a slow client must not stall every other
|
|
|
|
|
// session), so these flags are what stops a duplicate concurrent write to
|
|
|
|
|
// the same offset and keeps Complete/Cancel from consuming a session
|
|
|
|
|
// while bytes are still landing in its file.
|
|
|
|
|
writing map[int]struct{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *session) writingCount() int {
|
|
|
|
|
return len(s.writing)
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewManager(opts ManagerOptions) *Manager {
|
|
|
|
|
rootDir := opts.RootDir
|
|
|
|
|
if strings.TrimSpace(rootDir) == "" {
|
|
|
|
|
rootDir = filepath.Join(os.TempDir(), "silo-uploads")
|
|
|
|
|
}
|
|
|
|
|
ttl := opts.TTL
|
|
|
|
|
if ttl <= 0 {
|
|
|
|
|
ttl = defaultSessionTTL
|
|
|
|
|
}
|
|
|
|
|
maxSize := opts.MaxSize
|
|
|
|
|
if maxSize <= 0 {
|
|
|
|
|
maxSize = defaultMaxSize
|
|
|
|
|
}
|
|
|
|
|
maxChunkSize := opts.MaxChunkSize
|
|
|
|
|
if maxChunkSize <= 0 {
|
|
|
|
|
maxChunkSize = defaultChunkSize
|
|
|
|
|
}
|
|
|
|
|
now := opts.Now
|
|
|
|
|
if now == nil {
|
|
|
|
|
now = time.Now
|
|
|
|
|
}
|
|
|
|
|
return &Manager{
|
|
|
|
|
rootDir: rootDir,
|
|
|
|
|
ttl: ttl,
|
|
|
|
|
maxSize: maxSize,
|
|
|
|
|
maxChunkSize: maxChunkSize,
|
|
|
|
|
now: now,
|
|
|
|
|
sessions: make(map[string]*session),
|
2026-07-27 12:07:37 -04:00
|
|
|
detached: make(map[*session]struct{}),
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) MaxChunkSize() int64 {
|
|
|
|
|
return m.maxChunkSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) Create(req CreateRequest) (SessionInfo, error) {
|
|
|
|
|
filename := filepath.Base(strings.TrimSpace(req.Filename))
|
|
|
|
|
if filename == "." || filename == string(filepath.Separator) || filename == "" {
|
|
|
|
|
filename = "upload.bin"
|
|
|
|
|
}
|
|
|
|
|
if req.SizeBytes <= 0 {
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("%w: size_bytes must be positive", ErrInvalidRequest)
|
|
|
|
|
}
|
|
|
|
|
if req.SizeBytes > m.maxSize {
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("%w: maximum size is %d bytes", ErrTooLarge, m.maxSize)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chunkSize := req.ChunkSize
|
|
|
|
|
if chunkSize <= 0 {
|
|
|
|
|
chunkSize = m.maxChunkSize
|
|
|
|
|
}
|
|
|
|
|
if chunkSize > m.maxChunkSize {
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("%w: chunk_size must not exceed %d bytes", ErrInvalidRequest, m.maxChunkSize)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
totalChunks64 := (req.SizeBytes + chunkSize - 1) / chunkSize
|
|
|
|
|
if totalChunks64 <= 0 || totalChunks64 > math.MaxInt32 {
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("%w: invalid total chunk count", ErrInvalidRequest)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := os.MkdirAll(m.rootDir, 0o700); err != nil {
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("create upload root: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
id, err := newID()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return SessionInfo{}, err
|
|
|
|
|
}
|
|
|
|
|
dir := filepath.Join(m.rootDir, id)
|
|
|
|
|
if err := os.Mkdir(dir, 0o700); err != nil {
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("create upload session directory: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
path := filepath.Join(dir, "upload.bin")
|
|
|
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0o600)
|
|
|
|
|
if err != nil {
|
|
|
|
|
_ = os.RemoveAll(dir)
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("create upload session file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := file.Truncate(req.SizeBytes); err != nil {
|
|
|
|
|
_ = file.Close()
|
|
|
|
|
_ = os.RemoveAll(dir)
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("size upload session file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
if err := file.Close(); err != nil {
|
|
|
|
|
_ = os.RemoveAll(dir)
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("close upload session file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
now := m.now()
|
|
|
|
|
s := &session{
|
|
|
|
|
id: id,
|
|
|
|
|
filename: filename,
|
|
|
|
|
sizeBytes: req.SizeBytes,
|
|
|
|
|
chunkSize: chunkSize,
|
|
|
|
|
totalChunks: int(totalChunks64),
|
|
|
|
|
received: make([]bool, int(totalChunks64)),
|
|
|
|
|
path: path,
|
|
|
|
|
dir: dir,
|
|
|
|
|
expiresAt: now.Add(m.ttl),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
m.cleanupExpiredLocked(now)
|
|
|
|
|
m.sessions[id] = s
|
|
|
|
|
return s.info(), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 12:07:37 -04:00
|
|
|
// PutChunk streams one chunk into the session file. The body copy runs
|
|
|
|
|
// OUTSIDE the manager mutex — network reads from one slow client must not
|
|
|
|
|
// serialize every other session's chunk writes, completes, and cancels behind
|
|
|
|
|
// it. A per-chunk in-flight flag prevents duplicate concurrent writes to the
|
|
|
|
|
// same offset. Each begun and each accepted chunk refreshes the session
|
|
|
|
|
// expiry, making the TTL an idle timeout rather than an absolute deadline so
|
|
|
|
|
// a slow-but-progressing upload stays alive.
|
2026-06-06 00:05:04 -04:00
|
|
|
func (m *Manager) PutChunk(ctx context.Context, id string, index int, body io.Reader, contentLength int64) (SessionInfo, error) {
|
|
|
|
|
if body == nil {
|
|
|
|
|
return SessionInfo{}, fmt.Errorf("%w: chunk body is required", ErrInvalidChunk)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
m.cleanupExpiredLocked(m.now())
|
|
|
|
|
|
|
|
|
|
s, err := m.getLocked(id)
|
|
|
|
|
if err != nil {
|
2026-07-27 12:07:37 -04:00
|
|
|
m.mu.Unlock()
|
2026-06-06 00:05:04 -04:00
|
|
|
return SessionInfo{}, err
|
|
|
|
|
}
|
|
|
|
|
if s.completed {
|
2026-07-27 12:07:37 -04:00
|
|
|
m.mu.Unlock()
|
2026-06-06 00:05:04 -04:00
|
|
|
return SessionInfo{}, ErrAlreadyCompleted
|
|
|
|
|
}
|
|
|
|
|
if index < 0 || index >= s.totalChunks {
|
2026-07-27 12:07:37 -04:00
|
|
|
m.mu.Unlock()
|
2026-06-06 00:05:04 -04:00
|
|
|
return SessionInfo{}, fmt.Errorf("%w: chunk index is out of range", ErrInvalidChunk)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expectedSize := s.expectedChunkSize(index)
|
|
|
|
|
if contentLength >= 0 && contentLength != expectedSize {
|
2026-07-27 12:07:37 -04:00
|
|
|
m.mu.Unlock()
|
2026-06-06 00:05:04 -04:00
|
|
|
return SessionInfo{}, fmt.Errorf("%w: chunk size must be %d bytes", ErrInvalidChunk, expectedSize)
|
|
|
|
|
}
|
|
|
|
|
if expectedSize > m.maxChunkSize {
|
2026-07-27 12:07:37 -04:00
|
|
|
m.mu.Unlock()
|
2026-06-06 00:05:04 -04:00
|
|
|
return SessionInfo{}, fmt.Errorf("%w: maximum chunk size is %d bytes", ErrTooLarge, m.maxChunkSize)
|
|
|
|
|
}
|
|
|
|
|
if s.received[index] {
|
2026-07-27 12:07:37 -04:00
|
|
|
info := s.info()
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
return info, nil
|
|
|
|
|
}
|
|
|
|
|
if _, inFlight := s.writing[index]; inFlight {
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
return SessionInfo{}, ErrChunkBusy
|
|
|
|
|
}
|
|
|
|
|
if s.writing == nil {
|
|
|
|
|
s.writing = make(map[int]struct{})
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
2026-07-27 12:07:37 -04:00
|
|
|
s.writing[index] = struct{}{}
|
|
|
|
|
s.expiresAt = m.now().Add(m.ttl)
|
|
|
|
|
path := s.path
|
|
|
|
|
offset := int64(index) * s.chunkSize
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
written, copyErr := copyChunkBody(ctx, path, offset, body, expectedSize)
|
2026-06-06 00:05:04 -04:00
|
|
|
|
2026-07-27 12:07:37 -04:00
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
delete(s.writing, index)
|
|
|
|
|
// The session may have been canceled or expired while the copy ran. Its
|
|
|
|
|
// directory removal was deferred to the last finishing writer (see
|
|
|
|
|
// removeDetachedDirLocked); report the session gone either way.
|
|
|
|
|
if s.completed {
|
|
|
|
|
return SessionInfo{}, ErrAlreadyCompleted
|
|
|
|
|
}
|
|
|
|
|
if m.sessions[id] != s {
|
|
|
|
|
m.removeDetachedDirLocked(s)
|
|
|
|
|
return SessionInfo{}, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
if copyErr != nil {
|
|
|
|
|
return SessionInfo{}, copyErr
|
|
|
|
|
}
|
|
|
|
|
s.received[index] = true
|
|
|
|
|
s.receivedChunks++
|
|
|
|
|
s.receivedBytes += written
|
|
|
|
|
s.expiresAt = m.now().Add(m.ttl)
|
|
|
|
|
return s.info(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// removeDetachedDirLocked reclaims a detached session once its last in-flight
|
|
|
|
|
// writer has returned: drops it from the detached count and removes the spool
|
|
|
|
|
// directory. Safe to call for every finishing writer of a dropped session.
|
|
|
|
|
func (m *Manager) removeDetachedDirLocked(s *session) {
|
|
|
|
|
if s.writingCount() != 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
delete(m.detached, s)
|
|
|
|
|
_ = os.RemoveAll(s.dir)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// copyChunkBody writes exactly expectedSize bytes from body at offset,
|
|
|
|
|
// rejecting short or oversized chunks. Runs without the manager lock held.
|
|
|
|
|
func copyChunkBody(ctx context.Context, path string, offset int64, body io.Reader, expectedSize int64) (int64, error) {
|
|
|
|
|
file, err := os.OpenFile(path, os.O_WRONLY, 0o600)
|
2026-06-06 00:05:04 -04:00
|
|
|
if err != nil {
|
2026-07-27 12:07:37 -04:00
|
|
|
return 0, fmt.Errorf("open upload session file: %w", err)
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
defer file.Close()
|
|
|
|
|
|
2026-07-27 12:07:37 -04:00
|
|
|
writer := io.NewOffsetWriter(file, offset)
|
2026-06-06 00:05:04 -04:00
|
|
|
written, err := io.Copy(writer, io.LimitReader(body, expectedSize))
|
|
|
|
|
if err != nil {
|
2026-07-27 12:07:37 -04:00
|
|
|
return 0, err
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
if err := ctx.Err(); err != nil {
|
2026-07-27 12:07:37 -04:00
|
|
|
return 0, err
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
if written != expectedSize {
|
2026-07-27 12:07:37 -04:00
|
|
|
return 0, fmt.Errorf("%w: chunk size must be %d bytes", ErrInvalidChunk, expectedSize)
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
var extra [1]byte
|
|
|
|
|
extraBytes, err := body.Read(extra[:])
|
|
|
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
2026-07-27 12:07:37 -04:00
|
|
|
return 0, err
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
if extraBytes > 0 {
|
2026-07-27 12:07:37 -04:00
|
|
|
return 0, fmt.Errorf("%w: chunk size must be %d bytes", ErrInvalidChunk, expectedSize)
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
2026-07-27 12:07:37 -04:00
|
|
|
return written, nil
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) Complete(id string) (*CompletedUpload, error) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
m.cleanupExpiredLocked(m.now())
|
|
|
|
|
|
|
|
|
|
s, err := m.getLocked(id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if s.completed {
|
|
|
|
|
return nil, ErrAlreadyCompleted
|
|
|
|
|
}
|
|
|
|
|
if !s.isComplete() {
|
|
|
|
|
return nil, ErrIncomplete
|
|
|
|
|
}
|
|
|
|
|
s.completed = true
|
|
|
|
|
delete(m.sessions, id)
|
|
|
|
|
|
|
|
|
|
return &CompletedUpload{
|
|
|
|
|
ID: s.id,
|
|
|
|
|
Filename: s.filename,
|
|
|
|
|
SizeBytes: s.sizeBytes,
|
|
|
|
|
Path: s.path,
|
|
|
|
|
Cleanup: func() {
|
|
|
|
|
_ = os.RemoveAll(s.dir)
|
|
|
|
|
},
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 12:07:37 -04:00
|
|
|
// Peek reports the session's current state without mutating it beyond the
|
|
|
|
|
// shared expiry cleanup. Callers holding per-session state outside the
|
|
|
|
|
// manager (e.g. diagnostics chunked uploads) use it to notice sessions the
|
|
|
|
|
// manager has expired so their own bookkeeping can be released too.
|
|
|
|
|
func (m *Manager) Peek(id string) (SessionInfo, error) {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
s, err := m.getLocked(id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return SessionInfo{}, err
|
|
|
|
|
}
|
|
|
|
|
return s.info(), nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 00:05:04 -04:00
|
|
|
func (m *Manager) Cancel(id string) error {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
s, ok := m.sessions[id]
|
|
|
|
|
if !ok {
|
|
|
|
|
return ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
delete(m.sessions, id)
|
2026-07-27 12:07:37 -04:00
|
|
|
// With a chunk write in flight the file handle is still open; park the
|
|
|
|
|
// session as detached — still counted by DetachedWriterSessions — and the
|
|
|
|
|
// last finishing writer removes the directory.
|
|
|
|
|
if s.writingCount() == 0 {
|
|
|
|
|
return os.RemoveAll(s.dir)
|
|
|
|
|
}
|
|
|
|
|
m.detached[s] = struct{}{}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DetachedWriterSessions counts canceled/expired sessions whose chunk writers
|
|
|
|
|
// are still draining request bodies into spool files. Admission caps must
|
|
|
|
|
// include them: they hold connections and disk until each writer finishes or
|
|
|
|
|
// times out.
|
|
|
|
|
func (m *Manager) DetachedWriterSessions() int {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
defer m.mu.Unlock()
|
|
|
|
|
return len(m.detached)
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) getLocked(id string) (*session, error) {
|
|
|
|
|
id = strings.TrimSpace(id)
|
|
|
|
|
if id == "" {
|
|
|
|
|
return nil, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
s, ok := m.sessions[id]
|
|
|
|
|
if !ok {
|
|
|
|
|
return nil, ErrNotFound
|
|
|
|
|
}
|
|
|
|
|
if !m.now().Before(s.expiresAt) {
|
|
|
|
|
delete(m.sessions, id)
|
2026-07-27 12:07:37 -04:00
|
|
|
if s.writingCount() == 0 {
|
|
|
|
|
_ = os.RemoveAll(s.dir)
|
|
|
|
|
} else {
|
|
|
|
|
m.detached[s] = struct{}{}
|
|
|
|
|
}
|
2026-06-06 00:05:04 -04:00
|
|
|
return nil, ErrExpired
|
|
|
|
|
}
|
|
|
|
|
return s, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (m *Manager) cleanupExpiredLocked(now time.Time) {
|
|
|
|
|
for id, s := range m.sessions {
|
|
|
|
|
if now.Before(s.expiresAt) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
delete(m.sessions, id)
|
2026-07-27 12:07:37 -04:00
|
|
|
if s.writingCount() == 0 {
|
|
|
|
|
_ = os.RemoveAll(s.dir)
|
|
|
|
|
} else {
|
|
|
|
|
m.detached[s] = struct{}{}
|
|
|
|
|
}
|
2026-06-06 00:05:04 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 12:07:37 -04:00
|
|
|
// ReclaimOrphanedDirs deletes session directories under the manager's root
|
|
|
|
|
// that no live session owns. Call once at startup: after a process restart
|
|
|
|
|
// the in-memory session map is empty, but the previous process's partially
|
|
|
|
|
// uploaded spool directories survive on disk and would otherwise accumulate
|
|
|
|
|
// forever (no sweep ever discovers them). Returns the number of directories
|
|
|
|
|
// removed.
|
|
|
|
|
func (m *Manager) ReclaimOrphanedDirs() int {
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
live := make(map[string]struct{}, len(m.sessions))
|
|
|
|
|
for id := range m.sessions {
|
|
|
|
|
live[id] = struct{}{}
|
|
|
|
|
}
|
|
|
|
|
rootDir := m.rootDir
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
entries, err := os.ReadDir(rootDir)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
removed := 0
|
|
|
|
|
for _, entry := range entries {
|
|
|
|
|
if !entry.IsDir() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, ok := live[entry.Name()]; ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if os.RemoveAll(filepath.Join(rootDir, entry.Name())) == nil {
|
|
|
|
|
removed++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return removed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// StartExpirySweeper runs cleanup on the given interval until stop is closed.
|
|
|
|
|
// Without it, expiry only triggers from later API calls — sessions abandoned
|
|
|
|
|
// during a quiet period would otherwise hold their spool bytes indefinitely.
|
|
|
|
|
func (m *Manager) StartExpirySweeper(interval time.Duration, stop <-chan struct{}) {
|
|
|
|
|
if interval <= 0 {
|
|
|
|
|
interval = time.Minute
|
|
|
|
|
}
|
|
|
|
|
go func() {
|
|
|
|
|
ticker := time.NewTicker(interval)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-stop:
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
m.mu.Lock()
|
|
|
|
|
m.cleanupExpiredLocked(m.now())
|
|
|
|
|
m.mu.Unlock()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 00:05:04 -04:00
|
|
|
func (s *session) isComplete() bool {
|
|
|
|
|
return s.receivedChunks == s.totalChunks && s.receivedBytes == s.sizeBytes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *session) expectedChunkSize(index int) int64 {
|
|
|
|
|
offset := int64(index) * s.chunkSize
|
|
|
|
|
remaining := s.sizeBytes - offset
|
|
|
|
|
if remaining < s.chunkSize {
|
|
|
|
|
return remaining
|
|
|
|
|
}
|
|
|
|
|
return s.chunkSize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *session) info() SessionInfo {
|
|
|
|
|
return SessionInfo{
|
|
|
|
|
ID: s.id,
|
|
|
|
|
Filename: s.filename,
|
|
|
|
|
SizeBytes: s.sizeBytes,
|
|
|
|
|
ChunkSize: s.chunkSize,
|
|
|
|
|
TotalChunks: s.totalChunks,
|
|
|
|
|
ReceivedChunks: s.receivedChunks,
|
|
|
|
|
ReceivedBytes: s.receivedBytes,
|
|
|
|
|
Complete: s.receivedChunks == s.totalChunks && s.receivedBytes == s.sizeBytes,
|
|
|
|
|
ExpiresAt: s.expiresAt,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newID() (string, error) {
|
|
|
|
|
var b [16]byte
|
|
|
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
|
|
|
return "", fmt.Errorf("generate upload session id: %w", err)
|
|
|
|
|
}
|
|
|
|
|
return hex.EncodeToString(b[:]), nil
|
|
|
|
|
}
|