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
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:
1 parent
30c3c85f89
commit
37e33c656b
8 files changed
+137
-19
No files matched your search
@@ -13,7 +13,7 @@ async function setup(page: Page, scenario: 'queue' | 'template' | 'mine') {
|
|||||||
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
|
const source = structuredClone(mobileFixtures['/api/v1/admin/sources'].items[0]);
|
||||||
const file = structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]);
|
const file = structuredClone(mobileFixtures['/api/v1/admin/uploads'].items[0]);
|
||||||
const files = [
|
const files = [
|
||||||
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac' },
|
{ ...file, id: 'visible', original_name: 'visible.flac', target_source_name: source.name, target_rel_path: 'Artist/Album/01 - Song.flac', target_storage_path: 'nas:/music/Artist/Album/01 - Song.flac', stream_url: `/api/v1/admin/uploads/files/${file.id}/stream?exp=9999999999&sig=test` },
|
||||||
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
|
{ ...file, id: 'approved', original_name: 'approved.flac', status: 'approved' },
|
||||||
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
|
{ ...file, id: 'hidden', original_name: 'hidden.flac', status: 'processing' },
|
||||||
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
|
{ ...file, id: 'withdrawn', original_name: 'withdrawn.flac', metadata: null, status: 'withdrawn' },
|
||||||
@@ -78,7 +78,7 @@ test('queue shows the storage and path, and recalculates after fixing placement'
|
|||||||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeHidden();
|
||||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||||
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('nas:/music/Artist/Album/01 - Song.flac', { exact: true })).toBeVisible();
|
await expect(page.getByText('Artist/Album/01 - Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01 - Song.flac');
|
||||||
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
|
await expect(page.getByText('Хранилище не выбрано', { exact: true })).toBeVisible();
|
||||||
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
|
await page.getByRole('checkbox', { name: 'Выбрать всю пачку', exact: true }).check();
|
||||||
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
await page.getByRole('button', { name: 'Свернуть пачку', exact: true }).click();
|
||||||
@@ -156,7 +156,7 @@ test('queue shows the current full destination after saving a changed source tem
|
|||||||
}, path);
|
}, path);
|
||||||
await navigate('/admin/uploads');
|
await navigate('/admin/uploads');
|
||||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||||
await expect(page.getByText('nas:/music/Artist/Album/Song.flac', { exact: true })).toBeVisible();
|
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/Song.flac');
|
||||||
await navigate(sourcePath);
|
await navigate(sourcePath);
|
||||||
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
|
await page.getByRole('textbox', { name: 'Шаблон пути', exact: true }).fill('{albumartist}/{album}/{track:02}. {title}.{ext}');
|
||||||
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
|
const section = page.getByRole('heading', { name: 'Приём загрузок и шаблон пути' }).locator('..');
|
||||||
@@ -165,6 +165,24 @@ test('queue shows the current full destination after saving a changed source tem
|
|||||||
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
|
await expect(page.locator('html')).toHaveAttribute('data-saved-template', '{albumartist}/{album}/{track:02}. {title}.{ext}');
|
||||||
await navigate('/admin/uploads');
|
await navigate('/admin/uploads');
|
||||||
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||||
await expect(page.getByText('nas:/music/Artist/Album/01. Song.flac', { exact: true })).toBeVisible();
|
await expect(page.getByText('Artist/Album/01. Song.flac', { exact: true })).toHaveAttribute('title', 'nas:/music/Artist/Album/01. Song.flac');
|
||||||
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
|
await expect(page.getByText('Artist/Album/Song.flac', { exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('queue preview loads duration before play and plays signed audio', async ({ page }) => {
|
||||||
|
const dataSize = 8000 * 2 * 2;
|
||||||
|
const wav = Buffer.alloc(44 + dataSize);
|
||||||
|
wav.write('RIFF', 0); wav.writeUInt32LE(36 + dataSize, 4); wav.write('WAVEfmt ', 8);
|
||||||
|
wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22);
|
||||||
|
wav.writeUInt32LE(8000, 24); wav.writeUInt32LE(16000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34);
|
||||||
|
wav.write('data', 36); wav.writeUInt32LE(dataSize, 40);
|
||||||
|
await page.route('**/api/v1/admin/uploads/files/*/stream?*', (route) => route.fulfill({ contentType: 'audio/wav', body: wav }));
|
||||||
|
await setup(page, 'queue');
|
||||||
|
await page.getByRole('button', { name: 'Развернуть пачку', exact: true }).click();
|
||||||
|
const audio = page.locator('audio').first();
|
||||||
|
await expect(audio).toHaveAttribute('preload', 'metadata');
|
||||||
|
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.duration)).toBe(2);
|
||||||
|
await audio.evaluate((el: HTMLAudioElement) => el.play());
|
||||||
|
await expect.poll(() => audio.evaluate((el: HTMLAudioElement) => el.currentTime)).toBeGreaterThan(0);
|
||||||
|
expect(await audio.evaluate((el: HTMLAudioElement) => el.error)).toBeNull();
|
||||||
|
});
|
||||||
@@ -142,7 +142,7 @@ function QueueRow({
|
|||||||
</div>
|
</div>
|
||||||
<div className={u.rowActions}>
|
<div className={u.rowActions}>
|
||||||
{f.stream_url && (
|
{f.stream_url && (
|
||||||
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
<audio controls preload="metadata" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
|
||||||
)}
|
)}
|
||||||
{editable && (
|
{editable && (
|
||||||
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||||
@@ -160,14 +160,7 @@ function QueueRow({
|
|||||||
<span className={u.placementLabel}>{t(f.status === 'imported' ? 'uploads.destination_imported' : 'uploads.destination')}</span>
|
<span className={u.placementLabel}>{t(f.status === 'imported' ? 'uploads.destination_imported' : 'uploads.destination')}</span>
|
||||||
<strong>{f.target_source_name ?? t(f.target_source_id ? 'uploads.target_selected' : 'uploads.target_not_selected')}</strong>
|
<strong>{f.target_source_name ?? t(f.target_source_id ? 'uploads.target_selected' : 'uploads.target_not_selected')}</strong>
|
||||||
{f.target_rel_path ? (
|
{f.target_rel_path ? (
|
||||||
<>
|
<code className={u.target} title={f.target_storage_path ?? undefined}>{f.target_rel_path}</code>
|
||||||
<code className={u.target}>{f.target_rel_path}</code>
|
|
||||||
<span className={u.sub}>{t('uploads.target_path_hint')}</span>
|
|
||||||
{f.target_storage_path && <>
|
|
||||||
<span className={u.placementLabel}>{t('uploads.target_full_path')}</span>
|
|
||||||
<code className={u.target}>{f.target_storage_path}</code>
|
|
||||||
</>}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className={u.warn}>{t('uploads.target_path_missing')}</span>
|
<span className={u.warn}>{t('uploads.target_path_missing')}</span>
|
||||||
|
|||||||
+2
-2
@@ -157,8 +157,8 @@
|
|||||||
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
|
Пачки по умолчанию свёрнуты; кнопка в заголовке раскрывает файлы, выбор всей пачки доступен и без раскрытия.
|
||||||
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
|
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
|
||||||
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
|
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
|
||||||
с названием хранилища, путём относительно его корня и полным адресом назначения (локальный путь API
|
с названием хранилища и путём относительно его корня. Полный адрес назначения (локальный путь API
|
||||||
или `server:/export/path` для NFS). Для ожидающих рассмотрения, одобренных и ошибочных импортов
|
или `server:/export/path` для NFS) доступен при наведении на путь. Для ожидающих рассмотрения, одобренных и ошибочных импортов
|
||||||
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
предпросмотр рассчитывается при чтении очереди по текущему шаблону — тем же планировщиком, что и импорт.
|
||||||
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
Сохранение шаблона обновляет кеш очереди. Уже импортированные файлы сохраняют фактический путь.
|
||||||
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
Если рассчитать путь нельзя, показаны причина и действие для исправления вместо старого пути.
|
||||||
|
|||||||
@@ -292,7 +292,6 @@
|
|||||||
"filter_imported": "Added",
|
"filter_imported": "Added",
|
||||||
"filter_declined": "Not accepted",
|
"filter_declined": "Not accepted",
|
||||||
"filter_withdrawn": "Withdrawn",
|
"filter_withdrawn": "Withdrawn",
|
||||||
"target_full_path": "Full destination path",
|
|
||||||
"expand_batch": "Expand batch",
|
"expand_batch": "Expand batch",
|
||||||
"collapse_batch": "Collapse batch",
|
"collapse_batch": "Collapse batch",
|
||||||
"col_file": "Track",
|
"col_file": "Track",
|
||||||
@@ -369,7 +368,6 @@
|
|||||||
"destination_imported": "Saved to",
|
"destination_imported": "Saved to",
|
||||||
"target_selected": "Selected storage",
|
"target_selected": "Selected storage",
|
||||||
"target_not_selected": "No storage selected",
|
"target_not_selected": "No storage selected",
|
||||||
"target_path_hint": "Path relative to the storage root",
|
|
||||||
"target_path_missing": "Destination path has not been calculated",
|
"target_path_missing": "Destination path has not been calculated",
|
||||||
"choose_destination": "Choose storage",
|
"choose_destination": "Choose storage",
|
||||||
"fix_destination": "Fix destination",
|
"fix_destination": "Fix destination",
|
||||||
|
|||||||
@@ -292,7 +292,6 @@
|
|||||||
"filter_imported": "Добавлены",
|
"filter_imported": "Добавлены",
|
||||||
"filter_declined": "Не приняты",
|
"filter_declined": "Не приняты",
|
||||||
"filter_withdrawn": "Отозванные",
|
"filter_withdrawn": "Отозванные",
|
||||||
"target_full_path": "Полный путь назначения",
|
|
||||||
"expand_batch": "Развернуть пачку",
|
"expand_batch": "Развернуть пачку",
|
||||||
"collapse_batch": "Свернуть пачку",
|
"collapse_batch": "Свернуть пачку",
|
||||||
"col_file": "Трек",
|
"col_file": "Трек",
|
||||||
@@ -369,7 +368,6 @@
|
|||||||
"destination_imported": "Записано в",
|
"destination_imported": "Записано в",
|
||||||
"target_selected": "Выбранное хранилище",
|
"target_selected": "Выбранное хранилище",
|
||||||
"target_not_selected": "Хранилище не выбрано",
|
"target_not_selected": "Хранилище не выбрано",
|
||||||
"target_path_hint": "Путь относительно корня хранилища",
|
|
||||||
"target_path_missing": "Путь записи не рассчитан",
|
"target_path_missing": "Путь записи не рассчитан",
|
||||||
"choose_destination": "Выбрать хранилище",
|
"choose_destination": "Выбрать хранилище",
|
||||||
"fix_destination": "Исправить размещение",
|
"fix_destination": "Исправить размещение",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"pipu/api/internal/platform/apperr"
|
"pipu/api/internal/platform/apperr"
|
||||||
"pipu/api/internal/platform/httpx"
|
"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) &&
|
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/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
|
||||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
|
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
|
||||||
|
isUploadStreamPath(r.URL.Path) ||
|
||||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
|
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
|
||||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
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) {
|
func bearer(h string) (string, bool) {
|
||||||
const prefix = "bearer "
|
const prefix = "bearer "
|
||||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user