Files
silo-server/internal/api/handlers/compat_connect_info.go
148c9291c5 feat(web): add Connect Apps settings page for compat sign-in (#488)
* feat(web): add Connect Apps settings page for compat sign-in

Jellyfin-protocol clients offer one username box and one password box and
never prompt for a profile, so signing in requires `account#Profile` and
`password#PIN`. Nothing in the product taught that syntax, and every way of
getting it wrong surfaces as the same "invalid username or password", so it
became a recurring support burden.

Add Settings -> Connect Apps, which states the credentials for the signed-in
account rather than describing them in the abstract. The page is segmented by
app type: the two formats are never shown at once, each side names the apps it
covers, and the compat side is visually distinct so it cannot be mistaken for
the normal Silo login. The compat listener's separate address is shown too,
since pointing a client at the Silo address fails identically to a bad
password.

Backend adds GET /api/v1/compat/connect-info, an account-scoped read of the
compat listener's enabled flag, public URL, and server name. The admin status
endpoint already covers this ground for operators, but it also reports install
paths and version provenance, so it stays admin-only; this returns only what a
client learns by connecting anyway. It is auth-only and deliberately not
profile-scoped, since it describes how to sign in.

ConnectInfoForConfig shares the enabled-flag precedence with
WebComponentStatusForConfig via compatEnabled, so the two endpoints cannot
disagree about whether compat is on.

The page declines to display a username it knows cannot work: profile names
permit `#` but the resolver splits at the last one, so `alice#Movie #2` parses
as account "alice#Movie". Such profiles get an explanation and no copy button
instead of a string that fails to authenticate.

Part of #432

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(web): harden Connect Apps against misleading sign-in states

Review of the first pass found six ways the page could state something
untrue. Each one matters more than usual here, because the page exists
specifically to stop people guessing at credentials.

Report the running listener, not the stored setting. jellyfin_compat.enabled
is restart-required and cmd/silo builds the compat server from the boot
config alone, so the stored value describes intent. Reporting it promised
credentials for a listener that does not exist yet, or claimed the API was
off while the running one kept serving. ConnectInfo now returns the boot-time
state plus a pending_restart flag, and the page distinguishes "not running
yet" from "turned off". The admin status endpoint keeps reporting configured
intent, so the two intentionally diverge until a restart.

Stop presenting fetch failures as a disabled compat API. React Query clears
isLoading on error, so a failed request rendered "the compatibility API is
turned off" and sent users to an admin about a setting that was fine. A
failed profile list was worse: it fell through to an empty list and offered
the bare account name, which silently drops the profile suffix. Both now
withhold credentials and say the load failed.

Detect accounts that cannot use password login. Compat login is hardwired to
the local provider, which rejects accounts with local_password_login_enabled
false before checking any password, so SSO and plugin-provisioned accounts
can never authenticate. The page told them to type a password anyway; it now
says the compat API cannot accept the account.

Flag loopback compat addresses. jellyfin_compat.public_url defaults to
http://127.0.0.1:8096, which resolves to the client device on the phones and
TVs this page names. An untouched default was offered as the exact address to
copy; it is now explained instead of presented as usable.

Apply the #-in-profile-name guard to the summary list too. The selected-profile
field withheld an unusable username while "Every profile at a glance"
reintroduced it two sections below.

Read only the three settings this endpoint consumes. GetAll on the encrypted
repository decrypted every stored secret on each authenticated page view, and
an unrelated decryption failure would have silently dropped valid compat
overrides.

Invalidate the connect-info cache when an admin saves jellyfin_compat.*
settings. The address applies without a restart, but the page cached it for
five minutes and kept offering the old value for copying.

Part of #432

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:07:48 -04:00

104 lines
3.5 KiB
Go

package handlers
import (
"context"
"net/http"
"github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/jellycompat"
)
// compatConnectInfoKeys are the only settings this endpoint consults. They are
// read individually rather than via GetAll so an authenticated page view never
// loads or decrypts unrelated secrets, and so a decryption failure elsewhere in
// the settings table cannot mask a valid compat override.
var compatConnectInfoKeys = []string{
"jellyfin_compat.enabled",
"jellyfin_compat.public_url",
"jellyfin_compat.server_name",
}
// CompatConnectInfoHandler serves the account-facing view of the compatibility
// listeners: where to point a third-party client, and whether it is even on.
//
// The admin status endpoint covers the same ground for operators, but it also
// reports install paths and version provenance, so it stays admin-only. This
// handler exposes only the address a client would learn by connecting anyway.
type CompatConnectInfoHandler struct {
Config *config.Config
SettingsRepo ServerSettingsStore
Users UserRepository
}
func NewCompatConnectInfoHandler(
cfg *config.Config,
settings ServerSettingsStore,
users UserRepository,
) *CompatConnectInfoHandler {
return &CompatConnectInfoHandler{Config: cfg, SettingsRepo: settings, Users: users}
}
type compatConnectAccountInfo struct {
// PasswordLoginAvailable reports whether this account can authenticate
// with a password at all. Compat login is hardwired to the local provider,
// so SSO/plugin-provisioned accounts cannot sign in to a Jellyfin client
// no matter what they type.
PasswordLoginAvailable bool `json:"password_login_available"`
}
type compatConnectInfoResponse struct {
Jellyfin jellycompat.ConnectInfo `json:"jellyfin"`
Account compatConnectAccountInfo `json:"account"`
}
// HandleGetConnectInfo handles GET /compat/connect-info.
func (h *CompatConnectInfoHandler) HandleGetConnectInfo(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
writeJSON(w, http.StatusOK, compatConnectInfoResponse{
Jellyfin: jellycompat.ConnectInfoForConfig(h.Config, h.compatSettings(ctx)),
Account: compatConnectAccountInfo{
PasswordLoginAvailable: h.passwordLoginAvailable(ctx),
},
})
}
// compatSettings loads the stored overrides this endpoint cares about. A
// missing store or a failed read is not fatal: the bootstrap config alone still
// describes a usable listener, so fall back to it rather than failing a purely
// informational read.
func (h *CompatConnectInfoHandler) compatSettings(ctx context.Context) map[string]string {
if h.SettingsRepo == nil {
return nil
}
settings := make(map[string]string, len(compatConnectInfoKeys))
for _, key := range compatConnectInfoKeys {
value, err := h.SettingsRepo.Get(ctx, key)
if err != nil {
continue
}
settings[key] = value
}
return settings
}
// passwordLoginAvailable reports whether the requesting account can use the
// password flow. It defaults to true when the user cannot be resolved: the page
// then shows its normal instructions rather than telling someone their working
// login is unsupported because of a lookup blip.
func (h *CompatConnectInfoHandler) passwordLoginAvailable(ctx context.Context) bool {
if h.Users == nil {
return true
}
userID := middleware.GetUserID(ctx)
if userID == 0 {
return true
}
user, err := h.Users.GetByID(ctx, userID)
if err != nil || user == nil {
return true
}
return user.LocalPasswordLoginEnabled
}