2026-06-08 14:25:10 -04:00
|
|
|
package jellycompat
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/config"
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
|
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// fakeUserStore embeds userstore.UserStore so only ListProfiles needs an
|
|
|
|
|
// implementation; any other method call panics (the synthesis path must not
|
|
|
|
|
// touch them).
|
|
|
|
|
type fakeUserStore struct {
|
|
|
|
|
userstore.UserStore
|
|
|
|
|
profiles []userstore.Profile
|
|
|
|
|
listErr error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *fakeUserStore) ListProfiles(context.Context) ([]userstore.Profile, error) {
|
|
|
|
|
return s.profiles, s.listErr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type fakeUserStoreProvider struct {
|
|
|
|
|
store *fakeUserStore
|
|
|
|
|
forErr error
|
|
|
|
|
forCalls int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *fakeUserStoreProvider) ForUser(context.Context, int) (userstore.UserStore, error) {
|
|
|
|
|
p.forCalls++
|
|
|
|
|
if p.forErr != nil {
|
|
|
|
|
return nil, p.forErr
|
|
|
|
|
}
|
|
|
|
|
return p.store, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *fakeUserStoreProvider) Close() error { return nil }
|
|
|
|
|
|
|
|
|
|
// adminKeyAuthWithPrimary builds an authenticator backed by an admin key whose
|
|
|
|
|
// account has a non-primary and a primary profile (in that order, so the test
|
|
|
|
|
// proves IsPrimary selection is order-independent).
|
|
|
|
|
func adminKeyAuthWithPrimary(t *testing.T, clock func() time.Time) (*AdminAPIKeyAuthenticator, *fakeUserStoreProvider, *fakeAPIKeyValidator) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
|
|
|
|
|
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "admin", Role: "admin", Enabled: true}}
|
|
|
|
|
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{
|
|
|
|
|
{ID: "kids", Name: "Kids", IsPrimary: false},
|
|
|
|
|
{ID: "p1", Name: "Parent", IsPrimary: true},
|
|
|
|
|
}}}
|
|
|
|
|
keyAuth := NewAdminAPIKeyAuthenticator(validator, users, provider, clock)
|
|
|
|
|
if keyAuth == nil {
|
|
|
|
|
t.Fatal("expected non-nil authenticator")
|
|
|
|
|
}
|
|
|
|
|
return keyAuth, provider, validator
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func apiKeyRequest() *http.Request {
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
|
|
|
|
|
req.Header.Set("X-Emby-Token", "sa_test")
|
|
|
|
|
return req
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRequireSessionOrAPIKeySession_SynthesizesPrimaryProfile(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
keyAuth, provider, _ := adminKeyAuthWithPrimary(t, clock)
|
|
|
|
|
sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)}
|
|
|
|
|
|
|
|
|
|
var got *Session
|
|
|
|
|
h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
got = SessionFromContext(r.Context())
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, apiKeyRequest())
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got == nil {
|
|
|
|
|
t.Fatal("expected synthesized session in context")
|
|
|
|
|
}
|
|
|
|
|
if got.StreamAppUserID != 2 {
|
|
|
|
|
t.Errorf("StreamAppUserID = %d, want 2", got.StreamAppUserID)
|
|
|
|
|
}
|
|
|
|
|
if got.ProfileID != "p1" || got.ProfileName != "Parent" {
|
|
|
|
|
t.Errorf("profile = %q/%q, want p1/Parent", got.ProfileID, got.ProfileName)
|
|
|
|
|
}
|
|
|
|
|
if want := PseudoUserID(2, "p1"); got.PseudoUserID != want {
|
|
|
|
|
t.Errorf("PseudoUserID = %s, want %s", got.PseudoUserID, want)
|
|
|
|
|
}
|
|
|
|
|
if got.Username != "admin" {
|
|
|
|
|
t.Errorf("Username = %q, want admin", got.Username)
|
|
|
|
|
}
|
|
|
|
|
// Refresh-skip invariants: no upstream Silo token, zero expiry.
|
|
|
|
|
if got.StreamAppAccessToken != "" || got.StreamAppRefreshToken != "" {
|
|
|
|
|
t.Errorf("expected empty upstream tokens, got access=%q refresh=%q", got.StreamAppAccessToken, got.StreamAppRefreshToken)
|
|
|
|
|
}
|
|
|
|
|
if !got.StreamAppTokenExpiry.IsZero() {
|
|
|
|
|
t.Error("expected zero StreamAppTokenExpiry so RequireSession skips refresh")
|
|
|
|
|
}
|
|
|
|
|
if provider.forCalls != 1 {
|
|
|
|
|
t.Errorf("ForUser calls = %d, want 1", provider.forCalls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRequireSessionOrAPIKeySession_RejectsNonAdminKey(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
validator := &fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}}
|
|
|
|
|
users := &fakeAPIKeyUserLoader{user: &models.User{ID: 2, Username: "bob", Role: "user", Enabled: true}}
|
|
|
|
|
provider := &fakeUserStoreProvider{store: &fakeUserStore{profiles: []userstore.Profile{{ID: "p1", IsPrimary: true}}}}
|
|
|
|
|
keyAuth := NewAdminAPIKeyAuthenticator(validator, users, provider, clock)
|
|
|
|
|
sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)}
|
|
|
|
|
|
|
|
|
|
h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
|
|
|
t.Fatal("handler should not run for a non-admin key")
|
|
|
|
|
}))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, apiKeyRequest())
|
|
|
|
|
if rec.Code != http.StatusForbidden {
|
|
|
|
|
t.Fatalf("status = %d, want 403", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
// The non-admin account's profiles must never be consulted.
|
|
|
|
|
if provider.forCalls != 0 {
|
|
|
|
|
t.Errorf("ForUser calls = %d, want 0 (rejected before synthesis)", provider.forCalls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRequireSessionOrAPIKeySession_RejectsUnknownKey(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
keyAuth, _, _ := adminKeyAuthWithPrimary(t, clock)
|
|
|
|
|
sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)}
|
|
|
|
|
|
|
|
|
|
h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
|
|
|
t.Fatal("handler should not run for an unknown key")
|
|
|
|
|
}))
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Items", nil)
|
|
|
|
|
req.Header.Set("X-Emby-Token", "sa_doesnotmatch")
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRequireSessionOrAPIKeySession_FallsThroughWhenSynthesisUnavailable guards
|
|
|
|
|
// the regression where the middleware fronts the whole browse group: a nil
|
|
|
|
|
// authenticator or one without a UserStoreProvider must fall through to session
|
|
|
|
|
// auth (clean 401) rather than panic.
|
|
|
|
|
func TestRequireSessionOrAPIKeySession_FallsThroughWhenSynthesisUnavailable(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
sessionAuth := &Authenticator{sessions: NewSessionStore(time.Hour, clock)}
|
|
|
|
|
|
|
|
|
|
cases := map[string]*AdminAPIKeyAuthenticator{
|
|
|
|
|
"nil authenticator": nil,
|
|
|
|
|
"nil provider": NewAdminAPIKeyAuthenticator(
|
|
|
|
|
&fakeAPIKeyValidator{key: &models.APIKey{ID: 1, UserID: 2, Key: "sa_test"}},
|
|
|
|
|
&fakeAPIKeyUserLoader{user: &models.User{ID: 2, Role: "admin", Enabled: true}},
|
|
|
|
|
nil, clock,
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
for name, keyAuth := range cases {
|
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
|
h := RequireSessionOrAPIKeySession(sessionAuth, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
|
|
|
t.Fatal("handler should not run without a valid session")
|
|
|
|
|
}))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, apiKeyRequest()) // must not panic
|
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestResolveSession_RevalidatesEachCallCachesProfile: the key + owning user are
|
|
|
|
|
// validated on every call (so revocation is immediate), while the primary-profile
|
|
|
|
|
// lookup is cached until apiKeyProfileCacheTTL.
|
|
|
|
|
func TestResolveSession_RevalidatesEachCallCachesProfile(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
keyAuth, provider, validator := adminKeyAuthWithPrimary(t, clock)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
for i := 1; i <= 3; i++ {
|
|
|
|
|
if s, _, handled := keyAuth.resolveSession(ctx, "sa_test"); !handled || s == nil {
|
|
|
|
|
t.Fatalf("resolve %d should succeed", i)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if validator.getCalls != 3 {
|
|
|
|
|
t.Errorf("GetByKey calls = %d, want 3 (re-validated every request)", validator.getCalls)
|
|
|
|
|
}
|
|
|
|
|
if provider.forCalls != 1 {
|
|
|
|
|
t.Errorf("ForUser calls = %d, want 1 (profile cached)", provider.forCalls)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// After the profile TTL, the profile is re-fetched (auth still every call).
|
|
|
|
|
now = now.Add(apiKeyProfileCacheTTL + time.Second)
|
|
|
|
|
if s, _, handled := keyAuth.resolveSession(ctx, "sa_test"); !handled || s == nil {
|
|
|
|
|
t.Fatal("post-TTL resolve should succeed")
|
|
|
|
|
}
|
|
|
|
|
if validator.getCalls != 4 {
|
|
|
|
|
t.Errorf("GetByKey calls = %d, want 4", validator.getCalls)
|
|
|
|
|
}
|
|
|
|
|
if provider.forCalls != 2 {
|
|
|
|
|
t.Errorf("ForUser calls = %d, want 2 after profile TTL", provider.forCalls)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestResolveSession_RevocationIsImmediate: once a key is revoked, the very next
|
|
|
|
|
// request fails — no cached authorization decision survives (Finding 2).
|
|
|
|
|
func TestResolveSession_RevocationIsImmediate(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
keyAuth, _, validator := adminKeyAuthWithPrimary(t, clock)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
if s, _, handled := keyAuth.resolveSession(ctx, "sa_test"); !handled || s == nil {
|
|
|
|
|
t.Fatal("initial resolve should succeed")
|
|
|
|
|
}
|
|
|
|
|
validator.key = nil // revoke: GetByKey now returns not-found
|
|
|
|
|
|
|
|
|
|
s, res, handled := keyAuth.resolveSession(ctx, "sa_test")
|
|
|
|
|
if !handled {
|
|
|
|
|
t.Fatal("expected handled=true for an sa_ token")
|
|
|
|
|
}
|
|
|
|
|
if s != nil || res.ok {
|
|
|
|
|
t.Fatalf("revoked key must not resolve a session; got session=%v ok=%v", s != nil, res.ok)
|
|
|
|
|
}
|
|
|
|
|
if res.status != http.StatusUnauthorized {
|
|
|
|
|
t.Errorf("status = %d, want 401", res.status)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestPlaybackSessionAuth_APIKeyViaPlaySessionId: HLS follow-up requests carry
|
|
|
|
|
// only PlaySessionId. When the negotiated session's CompatToken is an sa_ key,
|
|
|
|
|
// PlaybackSessionAuth must still resolve it (Finding 1).
|
|
|
|
|
func TestPlaybackSessionAuth_APIKeyViaPlaySessionId(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
keyAuth, _, _ := adminKeyAuthWithPrimary(t, clock)
|
|
|
|
|
sessions := NewSessionStore(time.Hour, clock)
|
|
|
|
|
playback := NewPlaybackSessionStore(time.Hour, clock)
|
|
|
|
|
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
|
|
|
|
|
|
|
|
|
|
var got *Session
|
|
|
|
|
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
got = SessionFromContext(r.Context())
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// No token header / no api_key — only PlaySessionId, like an HLS segment URL.
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Videos/x/hls/p/seg0.ts?PlaySessionId=ps1", nil)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got == nil || got.Token != "sa_test" || got.StreamAppUserID != 2 {
|
|
|
|
|
t.Fatalf("expected synthesized api-key session (token sa_test, user 2); got %+v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestPlaybackSessionAuth_RevokedAPIKeyViaPlaySessionId: a revoked key fails even
|
|
|
|
|
// through the PlaySessionId fallback.
|
|
|
|
|
func TestPlaybackSessionAuth_RevokedAPIKeyViaPlaySessionId(t *testing.T) {
|
|
|
|
|
now := fixedNow()
|
|
|
|
|
clock := func() time.Time { return now }
|
|
|
|
|
keyAuth, _, validator := adminKeyAuthWithPrimary(t, clock)
|
|
|
|
|
validator.key = nil // revoked
|
|
|
|
|
sessions := NewSessionStore(time.Hour, clock)
|
|
|
|
|
playback := NewPlaybackSessionStore(time.Hour, clock)
|
|
|
|
|
playback.Put(PlaybackSession{ID: "ps1", CompatToken: "sa_test"})
|
|
|
|
|
|
|
|
|
|
h := PlaybackSessionAuth(sessions, playback, keyAuth)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
|
|
|
t.Fatal("handler should not run for a revoked key")
|
|
|
|
|
}))
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Videos/x/hls/p/seg0.ts?PlaySessionId=ps1", nil)
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(rec, req)
|
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestValidatePseudoUser confirms the path-userId guard a synthesized session
|
|
|
|
|
// relies on: empty or matching ids pass; a mismatched id 404s.
|
|
|
|
|
func TestValidatePseudoUser(t *testing.T) {
|
|
|
|
|
session := &Session{PseudoUserID: PseudoUserID(2, "p1")}
|
|
|
|
|
|
|
|
|
|
if !validatePseudoUser(httptest.NewRecorder(), "", session) {
|
|
|
|
|
t.Error("empty userID should pass")
|
|
|
|
|
}
|
|
|
|
|
if !validatePseudoUser(httptest.NewRecorder(), session.PseudoUserID.String(), session) {
|
|
|
|
|
t.Error("matching userID should pass")
|
|
|
|
|
}
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
if validatePseudoUser(rec, PseudoUserID(2, "other").String(), session) {
|
|
|
|
|
t.Error("mismatched userID should fail")
|
|
|
|
|
}
|
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("mismatch status = %d, want 404", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleUsers_ReturnsCallerAsSingleElement(t *testing.T) {
|
2026-06-10 19:25:07 -04:00
|
|
|
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
|
2026-06-08 14:25:10 -04:00
|
|
|
session := &Session{PseudoUserID: PseudoUserID(2, "p1"), Username: "admin"}
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/Users", nil)
|
|
|
|
|
req = req.WithContext(context.WithValue(req.Context(), compatSessionKey, session))
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.HandleUsers(rec, req)
|
|
|
|
|
|
|
|
|
|
if rec.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
var users []map[string]any
|
|
|
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &users); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v; body=%s", err, rec.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if len(users) != 1 {
|
|
|
|
|
t.Fatalf("len = %d, want 1 (current profile only)", len(users))
|
|
|
|
|
}
|
|
|
|
|
if users[0]["Id"] != session.PseudoUserID.String() {
|
|
|
|
|
t.Errorf("Id = %v, want %s", users[0]["Id"], session.PseudoUserID.String())
|
|
|
|
|
}
|
|
|
|
|
if users[0]["Name"] != "admin" {
|
|
|
|
|
t.Errorf("Name = %v, want admin", users[0]["Name"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleUsers_RequiresSession(t *testing.T) {
|
2026-06-10 19:25:07 -04:00
|
|
|
h := NewAuthHandler(func() *config.Config { return &config.Config{} }, nil, nil)
|
2026-06-08 14:25:10 -04:00
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
h.HandleUsers(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
|
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("status = %d, want 401", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRouter_UsersRequiresAuth(t *testing.T) {
|
|
|
|
|
cfg, err := config.LoadFromDB(map[string]string{})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("LoadFromDB: %v", err)
|
|
|
|
|
}
|
|
|
|
|
router := NewRouter(Dependencies{Config: cfg})
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/Users", nil))
|
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Fatalf("GET /Users status = %d, want 401", rec.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|