ci / db / db (push) Successful in 1m51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m40s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 33s
ci / ts / ts (packages + web) (push) Failing after 5m16s
210 lines
7.2 KiB
Go
210 lines
7.2 KiB
Go
package uploads
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"sync"
|
||
"syscall"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"github.com/google/uuid"
|
||
|
||
"pipu/api/internal/auth"
|
||
"pipu/api/internal/platform/httpx"
|
||
)
|
||
|
||
// Ядро протокола tus 1.0.0 (https://tus.io/protocols/resumable-upload): HEAD — сколько принято, PATCH — следующий
|
||
// кусок с Upload-Offset. Создание (extension creation) не нужно: файл заводит POST /uploads вместе с резервом квоты,
|
||
// а клиент получает готовый tus_url. Принимаемое смещение — фактический размер файла в staging.
|
||
|
||
const tusVersion = "1.0.0"
|
||
|
||
// MaxChunkBytes — предел одного PATCH (клиент шлёт по 8 МБ; nginx пропускает до 16 МБ).
|
||
const MaxChunkBytes = 64 << 20
|
||
|
||
type tusFiles interface {
|
||
File(ctx context.Context, id uuid.UUID) (File, error)
|
||
SetReceived(ctx context.Context, id uuid.UUID, offset int64) error
|
||
}
|
||
|
||
// TUS — приём файлов очереди.
|
||
type TUS struct {
|
||
files tusFiles
|
||
dir string // каталог staging
|
||
onComplete func(ctx context.Context, f File) error // файл принят целиком
|
||
log *slog.Logger
|
||
locks sync.Map // id → *sync.Mutex: один PATCH на файл одновременно
|
||
}
|
||
|
||
// NewTUS создаёт приёмник.
|
||
func NewTUS(files tusFiles, dir string, onComplete func(ctx context.Context, f File) error, log *slog.Logger) *TUS {
|
||
return &TUS{files: files, dir: dir, onComplete: onComplete, log: log}
|
||
}
|
||
|
||
// Mount регистрирует маршруты tus на роутере (`/api/v1/uploads/files/{id}`).
|
||
func (t *TUS) Mount(r chi.Router) {
|
||
r.Options("/api/v1/uploads/files/{id}", t.options)
|
||
r.Head("/api/v1/uploads/files/{id}", t.head)
|
||
r.Patch("/api/v1/uploads/files/{id}", t.patch)
|
||
}
|
||
|
||
func (t *TUS) options(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Tus-Resumable", tusVersion)
|
||
w.Header().Set("Tus-Version", tusVersion)
|
||
w.Header().Set("Tus-Max-Size", strconv.FormatInt(MaxChunkBytes, 10))
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// own — файл автора запроса в статусе, где tus имеет смысл.
|
||
func (t *TUS) own(w http.ResponseWriter, r *http.Request) (File, bool) {
|
||
w.Header().Set("Tus-Resumable", tusVersion)
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
p, ok := auth.PrincipalFrom(r.Context())
|
||
if !ok {
|
||
httpx.WriteProblem(w, http.StatusUnauthorized, auth.CodeUnauthorized, "not authenticated")
|
||
return File{}, false
|
||
}
|
||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||
if err != nil {
|
||
httpx.WriteProblem(w, http.StatusNotFound, CodeNotFound, "upload not found")
|
||
return File{}, false
|
||
}
|
||
f, err := t.files.File(r.Context(), id)
|
||
if err != nil || f.AuthorID != p.UserID {
|
||
httpx.WriteProblem(w, http.StatusNotFound, CodeNotFound, "upload not found")
|
||
return File{}, false
|
||
}
|
||
return f, true
|
||
}
|
||
|
||
func (t *TUS) path(f File) string { return filepath.Join(t.dir, filepath.FromSlash(f.StagingPath)) }
|
||
|
||
// onDisk — сколько байт уже лежит в staging.
|
||
func (t *TUS) onDisk(f File) int64 {
|
||
info, err := os.Stat(t.path(f))
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return info.Size()
|
||
}
|
||
|
||
func (t *TUS) head(w http.ResponseWriter, r *http.Request) {
|
||
f, ok := t.own(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
offset := f.Size // принят целиком и уже обрабатывается
|
||
switch {
|
||
case f.Status == StatusUploading:
|
||
offset = t.onDisk(f)
|
||
case !f.Status.Occupies():
|
||
w.WriteHeader(http.StatusGone)
|
||
return
|
||
}
|
||
w.Header().Set("Upload-Offset", strconv.FormatInt(offset, 10))
|
||
w.Header().Set("Upload-Length", strconv.FormatInt(f.Size, 10))
|
||
w.WriteHeader(http.StatusOK)
|
||
}
|
||
|
||
func (t *TUS) patch(w http.ResponseWriter, r *http.Request) {
|
||
f, ok := t.own(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if r.Header.Get("Tus-Resumable") != tusVersion {
|
||
w.Header().Set("Tus-Version", tusVersion)
|
||
w.WriteHeader(http.StatusPreconditionFailed)
|
||
return
|
||
}
|
||
if r.Header.Get("Content-Type") != "application/offset+octet-stream" {
|
||
httpx.WriteProblem(w, http.StatusUnsupportedMediaType, "common.bad_request", "content type must be application/offset+octet-stream")
|
||
return
|
||
}
|
||
if f.Status != StatusUploading {
|
||
httpx.WriteProblem(w, http.StatusConflict, CodeWrongStatus, "upload is not accepting data")
|
||
return
|
||
}
|
||
mu, _ := t.locks.LoadOrStore(f.ID, &sync.Mutex{})
|
||
lock := mu.(*sync.Mutex)
|
||
if !lock.TryLock() {
|
||
httpx.WriteProblem(w, http.StatusLocked, CodeOffset, "another request is writing this upload")
|
||
return
|
||
}
|
||
defer lock.Unlock()
|
||
|
||
offset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
|
||
have := t.onDisk(f)
|
||
if err != nil || offset != have {
|
||
w.Header().Set("Upload-Offset", strconv.FormatInt(have, 10))
|
||
httpx.WriteProblem(w, http.StatusConflict, CodeOffset, "offset does not match")
|
||
return
|
||
}
|
||
remaining := f.Size - have
|
||
if r.ContentLength > remaining || r.ContentLength > MaxChunkBytes {
|
||
httpx.WriteProblem(w, http.StatusRequestEntityTooLarge, CodeSizeMismatch, "chunk exceeds the declared size")
|
||
return
|
||
}
|
||
|
||
n, werr := t.append(f, r.Body, min(remaining, MaxChunkBytes))
|
||
now := have + n
|
||
if err := t.files.SetReceived(r.Context(), f.ID, now); err != nil {
|
||
t.log.ErrorContext(r.Context(), "tus: save offset", "upload_id", f.ID, "err", err)
|
||
}
|
||
if werr != nil {
|
||
if errors.Is(werr, errTooMuch) {
|
||
httpx.WriteProblem(w, http.StatusRequestEntityTooLarge, CodeSizeMismatch, "data exceeds the declared size")
|
||
return
|
||
}
|
||
// Обрыв посреди куска: принятое сохранено, клиент спросит HEAD и продолжит.
|
||
t.log.WarnContext(r.Context(), "tus: write interrupted", "upload_id", f.ID, "offset", now, "err", werr)
|
||
if errors.Is(werr, syscall.ENOSPC) || errors.Is(werr, syscall.EDQUOT) {
|
||
httpx.WriteProblem(w, http.StatusInsufficientStorage, "uploads.storage_full", "upload storage is full")
|
||
return
|
||
}
|
||
httpx.WriteProblem(w, http.StatusInternalServerError, "common.internal", "write interrupted")
|
||
return
|
||
}
|
||
if now == f.Size {
|
||
if err := t.onComplete(r.Context(), f); err != nil {
|
||
t.log.ErrorContext(r.Context(), "tus: complete", "upload_id", f.ID, "err", err)
|
||
httpx.WriteProblem(w, http.StatusInternalServerError, "common.internal", "cannot finish upload")
|
||
return
|
||
}
|
||
}
|
||
w.Header().Set("Upload-Offset", strconv.FormatInt(now, 10))
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
var errTooMuch = errors.New("uploads: more data than declared")
|
||
|
||
// append дописывает тело в конец файла staging, не больше limit байт; лишние данные — ошибка.
|
||
func (t *TUS) append(f File, body io.Reader, limit int64) (int64, error) {
|
||
p := t.path(f)
|
||
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
|
||
return 0, err
|
||
}
|
||
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
n, err := io.Copy(out, io.LimitReader(body, limit))
|
||
if cerr := out.Close(); err == nil {
|
||
err = cerr
|
||
}
|
||
if err != nil {
|
||
return n, err
|
||
}
|
||
var extra [1]byte
|
||
if m, _ := body.Read(extra[:]); m > 0 {
|
||
// Лишнее на диск не попало — его отсёк LimitReader.
|
||
return n, errTooMuch
|
||
}
|
||
return n, nil
|
||
}
|