feat(app): add internal components batch 3
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const embyFixtureCover = "https://p3-shortvideo.byteimg.com/synthetic.png?signature=private-cover-token"
|
||||
|
||||
type embyPosterTestTransport func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (transport embyPosterTestTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return transport(request)
|
||||
}
|
||||
|
||||
func embySyntheticPoster(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var data bytes.Buffer
|
||||
if err := png.Encode(&data, image.NewRGBA(image.Rect(0, 0, 1, 1))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data.Bytes()
|
||||
}
|
||||
|
||||
func TestEmbyArchiveIncludesSignedPosterMetadataOnly(t *testing.T) {
|
||||
drama := Drama{ID: historyFixtureDramaID, Title: "海报 <&>", CoverURL: embyFixtureCover}
|
||||
key := bytes.Repeat([]byte{7}, 32)
|
||||
body, err := buildEmbyArchive(drama, embySyncTestChapters(), "https://library.test/juku", key, "poster-owner")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(archive.File) != 5 {
|
||||
t.Fatal("export should contain only STRM and NFO files")
|
||||
}
|
||||
found := false
|
||||
for _, file := range archive.File {
|
||||
if !strings.HasSuffix(file.Name, ".strm") && !strings.HasSuffix(file.Name, ".nfo") {
|
||||
t.Fatal("export created an artwork or media file", file.Name)
|
||||
}
|
||||
if !strings.HasSuffix(file.Name, "/tvshow.nfo") {
|
||||
continue
|
||||
}
|
||||
reader, _ := file.Open()
|
||||
var nfo struct {
|
||||
Title string `xml:"title"`
|
||||
Thumb struct {
|
||||
URL string `xml:",chardata"`
|
||||
Aspect string `xml:"aspect,attr"`
|
||||
} `xml:"thumb"`
|
||||
Identity struct {
|
||||
Value string `xml:",chardata"`
|
||||
Type string `xml:"type,attr"`
|
||||
} `xml:"uniqueid"`
|
||||
}
|
||||
err = xml.NewDecoder(reader).Decode(&nfo)
|
||||
reader.Close()
|
||||
if err != nil || nfo.Title != drama.Title || nfo.Thumb.Aspect != "poster" || nfo.Identity.Type != "juku" || nfo.Identity.Value != embyMetadataID(key, drama.ID) {
|
||||
t.Fatal("invalid NFO metadata", err)
|
||||
}
|
||||
cover, err := url.Parse(nfo.Thumb.URL)
|
||||
if err != nil || cover.Scheme != "https" || cover.Host != "library.test" || cover.Path != "/juku/api/emby/cover" {
|
||||
t.Fatal("cover URL does not preserve the configured reverse proxy path")
|
||||
}
|
||||
query := cover.Query()
|
||||
if query.Get("id") != drama.ID || query.Get("account") != "poster-owner" ||
|
||||
query.Get("key") != embyCoverToken(key, drama.ID, "poster-owner", query.Get("v")) || strings.Contains(nfo.Thumb.URL, "private-cover-token") {
|
||||
t.Fatal("cover URL is not scoped to this account and drama")
|
||||
}
|
||||
found = true
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("missing tvshow.nfo")
|
||||
}
|
||||
drama.CoverURL = "https://untrusted.example.invalid/image"
|
||||
if embyCoverURL(drama, "https://library.test", key, "poster-owner") != "" {
|
||||
t.Fatal("untrusted source was included in the exported poster URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyCoverWithoutCookiesChecksSignatureAndCurrentPermissions(t *testing.T) {
|
||||
app, admin := administratorFixture(t)
|
||||
member := newAccountTestBrowser(t, app)
|
||||
member.register(t, "poster-viewer")
|
||||
store := app.browserViewers().accountStore()
|
||||
owner := store.state.Accounts["poster-viewer"].ID
|
||||
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": true}))
|
||||
app.dramas[0].CoverURL = embyFixtureCover
|
||||
data := embySyntheticPoster(t)
|
||||
var calls atomic.Int32
|
||||
app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
if request.URL.String() != embyFixtureCover || request.Header.Get("Referer") == "" || request.Header.Get("X-Emby-Token") != "" {
|
||||
t.Error("cover request lost source headers or leaked Emby credentials")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil
|
||||
})
|
||||
key, err := app.embySigningKey(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := embyCoverURL(app.dramas[0], "http://library.test", key, owner)
|
||||
request := func(handler http.Handler, method, address string) *httptest.ResponseRecorder {
|
||||
result := httptest.NewRecorder()
|
||||
handler.ServeHTTP(result, httptest.NewRequest(method, address, nil))
|
||||
return result
|
||||
}
|
||||
for _, mutation := range []struct{ name, value string }{
|
||||
{"id", "hongguo:7000000000000000002"}, {"account", "another-account"}, {"v", strings.Repeat("0", 32)},
|
||||
{"key", embyToken(key, historyFixtureDramaID, "chapter-1", owner)}, {"key", "invalid"},
|
||||
} {
|
||||
parsed, _ := url.Parse(address)
|
||||
query := parsed.Query()
|
||||
query.Set(mutation.name, mutation.value)
|
||||
parsed.RawQuery = query.Encode()
|
||||
if result := request(app.routes(), http.MethodGet, parsed.String()); result.Code != 403 {
|
||||
t.Fatal("forged cover was accepted", mutation.name, result.Code)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("invalid signatures reached the source")
|
||||
}
|
||||
result := request(app.routes(), http.MethodGet, address)
|
||||
if result.Code != 200 || !bytes.Equal(result.Body.Bytes(), data) || result.Header().Get("Content-Type") != "image/png" ||
|
||||
result.Header().Get("Cache-Control") != "private, no-store" {
|
||||
t.Fatal("signed cover was not usable without browser cookies", result.Code)
|
||||
}
|
||||
head := request(app.routes(), http.MethodHead, address)
|
||||
if head.Code != 200 || head.Body.Len() != 0 || calls.Load() != 1 {
|
||||
t.Fatal("cover HEAD/cache failed")
|
||||
}
|
||||
if result := request(app.routes(), http.MethodPost, address); result.Code != 405 {
|
||||
t.Fatal("unsupported cover method accepted")
|
||||
}
|
||||
restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, dramas: app.dramas}
|
||||
if result := request(restarted.routes(), http.MethodGet, address); result.Code != 200 {
|
||||
t.Fatal("poster link did not survive restart", result.Code)
|
||||
}
|
||||
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/accounts/permissions", map[string]any{"username": "poster-viewer", "onlineOnly": true}))
|
||||
before := calls.Load()
|
||||
if result := request(app.routes(), http.MethodGet, address); result.Code != 403 || calls.Load() != before {
|
||||
t.Fatal("online-only account retained poster export access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPosterMatchesIdentityAndPortableFolderPaths(t *testing.T) {
|
||||
folder, identity := "同名剧 [0123456789abcdef]", "fixture-provider-id"
|
||||
for _, prefix := range []string{"/mnt/emby/", "D:\\Media\\Emby\\"} {
|
||||
item, err := matchEmbySeries([]embyMediaItem{
|
||||
{ID: "other", Type: "Series", Path: prefix + "同名剧 [fedcba9876543210]"},
|
||||
{ID: "wanted", Type: "Series", Path: prefix + folder, ProviderIDs: map[string]string{"Juku": identity}},
|
||||
}, folder, identity)
|
||||
if err != nil || item.ID != "wanted" {
|
||||
t.Fatal("portable folder/identity match failed", item.ID, err)
|
||||
}
|
||||
}
|
||||
items := []embyMediaItem{
|
||||
{ID: "first", Type: "Series", Path: "/one/" + folder},
|
||||
{ID: "second", Type: "Series", Path: "/two/" + folder},
|
||||
}
|
||||
if _, err := matchEmbySeries(items, folder, identity); err == nil {
|
||||
t.Fatal("ambiguous folders were matched by title")
|
||||
}
|
||||
items[0].ProviderIDs = map[string]string{"juku": "different-instance"}
|
||||
items[1].ID = "../escape"
|
||||
if item, err := matchEmbySeries(items, folder, identity); err != nil || item.ID != "" {
|
||||
t.Fatal("wrong instance or unsafe item ID accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPostersScanDelayRetryRestartAndCustomArtwork(t *testing.T) {
|
||||
app, admin := administratorFixture(t)
|
||||
manager := app.embySyncer()
|
||||
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/following", map[string]any{"dramaId": historyFixtureDramaID, "saved": true}))
|
||||
viewerResultOK(t, admin.request(t, http.MethodPost, "/api/ui/admin/settings", map[string]bool{"requireLogin": true, "allowRegistration": true}))
|
||||
app.dramas[0].CoverURL = embyFixtureCover
|
||||
data := embySyntheticPoster(t)
|
||||
var imageRequests atomic.Int32
|
||||
app.downloader.client.Transport = embyPosterTestTransport(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Host != "p3-shortvideo.byteimg.com" {
|
||||
t.Error("unexpected external request")
|
||||
return nil, errors.New("external requests forbidden")
|
||||
}
|
||||
imageRequests.Add(1)
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(data)), Request: request}, nil
|
||||
})
|
||||
library := httptest.NewServer(app.routes())
|
||||
defer library.Close()
|
||||
key, err := app.embySigningKey(true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
folder := embyFolderName(app.dramas[0])
|
||||
var ready, failDownload, delayConfirmation atomic.Bool
|
||||
var scans, uploads atomic.Int32
|
||||
var imageTag atomic.Value
|
||||
imageTag.Store("")
|
||||
item := func() embyMediaItem {
|
||||
return embyMediaItem{ID: "series-1", Type: "Series", Path: "/container/shared/" + folder,
|
||||
ProviderIDs: map[string]string{"juku": embyMetadataID(key, historyFixtureDramaID)}, ImageTags: map[string]string{"Primary": imageTag.Load().(string)}}
|
||||
}
|
||||
emby := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Header.Get("X-Emby-Token") != "private-api-key" || strings.Contains(request.URL.RawQuery, "private-api-key") {
|
||||
t.Error("API key missing from header or exposed in URL")
|
||||
}
|
||||
switch request.URL.Path {
|
||||
case "/emby/Library/Refresh":
|
||||
scans.Add(1)
|
||||
writer.WriteHeader(204)
|
||||
case "/emby/Items":
|
||||
result := embyItemList{}
|
||||
if ready.Load() && !(request.URL.Query().Get("Ids") != "" && delayConfirmation.Load()) {
|
||||
result.Items, result.TotalRecordCount = []embyMediaItem{item()}, 1
|
||||
}
|
||||
json.NewEncoder(writer).Encode(result)
|
||||
case "/emby/Items/series-1/RemoteImages/Download":
|
||||
uploads.Add(1)
|
||||
if failDownload.Load() {
|
||||
http.Error(writer, "sensitive upstream body private-api-key", 503)
|
||||
return
|
||||
}
|
||||
if request.Method != http.MethodPost || request.URL.Query().Get("Type") != "Primary" || request.Header.Get("Content-Type") != "application/json" {
|
||||
t.Error("invalid poster download request")
|
||||
}
|
||||
address := request.URL.Query().Get("ImageUrl")
|
||||
if !strings.HasPrefix(address, library.URL+"/api/emby/cover?") {
|
||||
t.Error("Emby was sent a source URL instead of the signed application URL")
|
||||
writer.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
response, err := http.Get(address)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
writer.WriteHeader(502)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if err != nil || response.StatusCode != 200 || !bytes.Equal(body, data) {
|
||||
t.Error("Emby could not fetch the synthetic cover without cookies", response.StatusCode)
|
||||
writer.WriteHeader(502)
|
||||
return
|
||||
}
|
||||
imageTag.Store("managed-" + request.URL.Query().Get("ImageUrl"))
|
||||
writer.WriteHeader(204)
|
||||
default:
|
||||
t.Error("unexpected Emby request", request.URL.Path)
|
||||
writer.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer emby.Close()
|
||||
manager.document.OwnerID = app.browserViewers().accountStore().state.Accounts["admin"].ID
|
||||
manager.document.Settings = embySyncSettings{Enabled: true, Following: true, IntervalMinutes: 60, OutputDir: t.TempDir(), BaseURL: library.URL, ServerURL: emby.URL + "/emby", APIKey: "private-api-key"}
|
||||
forcePoster := func() {
|
||||
entry := manager.document.Entries[historyFixtureDramaID]
|
||||
entry.Poster.CheckedAt, entry.Poster.RetryAt = time.Time{}, time.Time{}
|
||||
manager.document.Entries[historyFixtureDramaID] = entry
|
||||
}
|
||||
run := func() embySyncEntry {
|
||||
manager.runOnce(context.Background())
|
||||
return manager.document.Entries[historyFixtureDramaID]
|
||||
}
|
||||
first := run()
|
||||
if first.Episodes != 3 || first.Error != "" || first.Poster.Status != "waiting" || scans.Load() != 1 || uploads.Load() != 0 ||
|
||||
manager.document.PostersPending != 1 || time.Until(manager.document.NextRunAt) > 2*time.Minute {
|
||||
t.Fatal("first scan did not queue a durable poster retry", first.Poster.Status, manager.document.Error)
|
||||
}
|
||||
file := filepath.Join(manager.document.Settings.OutputDir, first.Folder, "Season 01", "S01E001.strm")
|
||||
before, _ := os.Stat(file)
|
||||
ready.Store(true)
|
||||
forcePoster()
|
||||
second := run()
|
||||
after, _ := os.Stat(file)
|
||||
if second.Poster.Status != "synced" || uploads.Load() != 1 || imageRequests.Load() != 1 || manager.document.WrittenFiles != 0 ||
|
||||
scans.Load() != 1 || !before.ModTime().Equal(after.ModTime()) {
|
||||
t.Fatal("poster retry rewrote/refetched episodes or did not synchronize", second.Poster.Status, manager.document.Error)
|
||||
}
|
||||
forcePoster()
|
||||
run()
|
||||
if uploads.Load() != 1 {
|
||||
t.Fatal("unchanged artwork was downloaded again")
|
||||
}
|
||||
restarted := &UIApp{cfg: app.cfg, downloader: app.downloader, dramas: app.dramas}
|
||||
manager = restarted.embySyncer()
|
||||
if manager.loadErr != nil || manager.document.Entries[historyFixtureDramaID].Poster.ImageTag == "" {
|
||||
t.Fatal("restart lost managed artwork state", manager.loadErr)
|
||||
}
|
||||
forcePoster()
|
||||
run()
|
||||
if uploads.Load() != 1 || scans.Load() != 1 {
|
||||
t.Fatal("restart duplicated existing artwork or files")
|
||||
}
|
||||
setCover := func(address string) {
|
||||
app.mu.Lock()
|
||||
app.dramas = append([]Drama(nil), app.dramas...)
|
||||
app.dramas[0].CoverURL = address
|
||||
dramas := append([]Drama(nil), app.dramas...)
|
||||
app.mu.Unlock()
|
||||
restarted.mu.Lock()
|
||||
restarted.dramas = dramas
|
||||
restarted.mu.Unlock()
|
||||
}
|
||||
setCover(embyFixtureCover + "&revision=2")
|
||||
failDownload.Store(true)
|
||||
failed := run()
|
||||
if failed.Error != "" || failed.Poster.Status != "failed" || manager.document.PostersFailed != 1 ||
|
||||
!strings.Contains(failed.Poster.Error, "HTTP 503") || time.Until(failed.Poster.RetryAt) < 30*time.Second {
|
||||
t.Fatal("poster failure affected episode import or lost retry state", failed.Poster.Status, manager.document.Error)
|
||||
}
|
||||
serialized, _ := json.Marshal(manager.view())
|
||||
if bytes.Contains(serialized, []byte("private-api-key")) || bytes.Contains(serialized, []byte("private-cover-token")) {
|
||||
t.Fatal("sync status exposed private URLs or credentials")
|
||||
}
|
||||
count := uploads.Load()
|
||||
run()
|
||||
if uploads.Load() != count {
|
||||
t.Fatal("failed artwork ignored backoff")
|
||||
}
|
||||
failDownload.Store(false)
|
||||
delayConfirmation.Store(true)
|
||||
forcePoster()
|
||||
unconfirmed := run()
|
||||
if unconfirmed.Poster.SyncedSource == "" || unconfirmed.Poster.Status != "failed" {
|
||||
t.Fatal("successful submission with delayed confirmation was lost")
|
||||
}
|
||||
count = uploads.Load()
|
||||
delayConfirmation.Store(false)
|
||||
imageTag.Store("custom-during-confirmation-failure")
|
||||
forcePoster()
|
||||
if confirmed := run(); confirmed.Poster.Status != "existing" || confirmed.Poster.ImageTag != "" || uploads.Load() != count {
|
||||
t.Fatal("unconfirmed image ownership overwrote or adopted custom artwork")
|
||||
}
|
||||
imageTag.Store("user-custom-cover")
|
||||
forcePoster()
|
||||
if custom := run(); custom.Poster.Status != "existing" || uploads.Load() != count {
|
||||
t.Fatal("custom artwork was overwritten")
|
||||
}
|
||||
setCover(embyFixtureCover + "&revision=3")
|
||||
if custom := run(); custom.Poster.Status != "existing" || uploads.Load() != count {
|
||||
t.Fatal("metadata update overwrote custom artwork")
|
||||
}
|
||||
imageTag.Store("")
|
||||
forcePoster()
|
||||
if repaired := run(); repaired.Poster.Status != "synced" || uploads.Load() != count+1 {
|
||||
t.Fatal("missing artwork was not repaired")
|
||||
}
|
||||
err = filepath.WalkDir(manager.document.Settings.OutputDir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err == nil && !entry.IsDir() && !strings.HasSuffix(path, ".strm") && !strings.HasSuffix(path, ".nfo") && entry.Name() != ".juku-emby.json" && entry.Name() != "poster.png" {
|
||||
t.Error("synchronization wrote an unexpected file into the export directory", entry.Name())
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := filepath.Join(manager.document.Settings.OutputDir, folder, "poster.png")
|
||||
if body, err := os.ReadFile(local); err != nil || !bytes.Equal(body, data) {
|
||||
t.Fatal("automatic sync did not preserve the local poster", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPosterAPIDoesNotForwardCredentialsOnRedirect(t *testing.T) {
|
||||
var forwarded atomic.Int32
|
||||
target := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
forwarded.Add(1)
|
||||
writer.WriteHeader(200)
|
||||
}))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
http.Redirect(writer, request, target.URL, http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
client := newEmbyAPIClient(embySyncSettings{ServerURL: redirect.URL, APIKey: "private-api-key"})
|
||||
defer client.close()
|
||||
if _, err := client.series(context.Background()); err == nil || !strings.Contains(err.Error(), "HTTP 302") || forwarded.Load() != 0 {
|
||||
t.Fatal("Emby request followed a redirect with credentials", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user