fix(uploads): allow signed audio previews and compact destination details
ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 47s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m42s
ci / ts / ts (packages + web) (push) Failing after 5m24s
ci / api / api (push) Successful in 42s

This commit is contained in:
mrleo1nid committed 2026-10-29 11:21:48 +03:00
1 parent 30c3c85f89
commit 37e33c656b
8 files changed
+137 -19

No files matched your search

+18
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"strings"
"github.com/google/uuid"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
@@ -95,6 +97,7 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
isUploadStreamPath(r.URL.Path) ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
@@ -130,6 +133,21 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
}
}
// Only the signed audio endpoint bypasses Bearer authentication. Its handler
// verifies the signature and expiry before accessing staging.
func isUploadStreamPath(urlPath string) bool {
id, ok := strings.CutPrefix(urlPath, "/api/v1/admin/uploads/files/")
if !ok {
return false
}
id, ok = strings.CutSuffix(id, "/stream")
if !ok {
return false
}
_, err := uuid.Parse(id)
return err == nil
}
func bearer(h string) (string, bool) {
const prefix = "bearer "
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
@@ -0,0 +1,38 @@
package auth_test
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
)
func TestSignedUploadStreamReachesSignatureHandlerWithoutBearer(t *testing.T) {
const file = "/api/v1/admin/uploads/files/01a18459-32bc-7589-a83d-635292a7a4e2"
for _, tc := range []struct {
method, path string
want int
}{
{http.MethodGet, file + "/stream?exp=1&sig=invalid", http.StatusForbidden},
{http.MethodHead, file + "/stream?exp=1&sig=invalid", http.StatusForbidden},
{http.MethodGet, file, http.StatusUnauthorized},
{http.MethodPatch, file + "/stream", http.StatusUnauthorized},
{http.MethodGet, file + "/stream/other", http.StatusUnauthorized},
{http.MethodGet, "/api/v1/admin/uploads", http.StatusUnauthorized},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
h := auth.Middleware(nil, slog.New(slog.NewTextHandler(io.Discard, nil)))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// A signed media route still delegates authorization to its signature handler.
w.WriteHeader(http.StatusForbidden)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
require.Equal(t, tc.want, rec.Code)
})
}
}
@@ -0,0 +1,55 @@
package uploads
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/sign"
)
func TestUploadStreamRangeAndAccel(t *testing.T) {
file := filepath.Join(t.TempDir(), "audio")
require.NoError(t, os.WriteFile(file, []byte("fLaC0123456789"), 0600))
req := httptest.NewRequest(http.MethodGet, "/audio", nil)
req.Header.Set("Range", "bytes=4-7")
rec := httptest.NewRecorder()
require.NoError(t, (uploadStream{req: req, file: file, format: "flac"}).VisitStreamUploadResponse(rec))
require.Equal(t, http.StatusPartialContent, rec.Code)
require.Equal(t, "audio/flac", rec.Header().Get("Content-Type"))
require.Equal(t, "bytes 4-7/14", rec.Header().Get("Content-Range"))
require.Equal(t, "0123", rec.Body.String())
rec = httptest.NewRecorder()
require.NoError(t, (uploadStream{format: "flac", xAccel: true, accel: "/_internal/uploads/batch/file"}).VisitStreamUploadResponse(rec))
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "/_internal/uploads/batch/file", rec.Header().Get("X-Accel-Redirect"))
require.Equal(t, "audio/flac", rec.Header().Get("Content-Type"))
}
func TestUploadStreamRejectsInvalidAndExpiredLinksBeforeReadingStaging(t *testing.T) {
signer, err := sign.New("")
require.NoError(t, err)
h := NewHandler(nil, signer, false)
id := uuid.New()
for _, expired := range []bool{false, true} {
exp := time.Now().Add(time.Hour).Unix()
sig := "invalid"
if expired {
exp = time.Now().Add(-time.Hour).Unix()
sig = signer.Sign("upload", id.String(), strconv.FormatInt(exp, 10))
}
res, err := h.StreamUpload(context.Background(), gen.StreamUploadRequestObject{Id: id, Params: gen.StreamUploadParams{Exp: exp, Sig: sig}})
require.Nil(t, res)
require.ErrorContains(t, err, "link is invalid or expired")
}
}