package jellyfin import ( "context" "encoding/json" "errors" "net/http" "github.com/mithral/vellum/internal/apitoken" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" "github.com/mithral/vellum/internal/jellyfin/dto" ) func (h *Handlers) handleSystemInfoPublic(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, dto.SystemInfoPublic{ Id: h.serverIDFor(r.Context()), ServerName: "Vellum", Version: dto.Version, ProductName: "Vellum", OperatingSystem: "Linux", }) } func (h *Handlers) handleAuthenticateByName(w http.ResponseWriter, r *http.Request) { // Deliberately not httpx.DecodeJSON: that rejects unknown fields, and real // clients add extras. We only read Username and Pw. var req dto.AuthByNameRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid request body") return } if req.Username == "" || req.Pw == "" { httpx.WriteError(w, http.StatusBadRequest, "username and password are required") return } u, err := auth.Authenticate(r.Context(), h.db, req.Username, req.Pw) if err != nil { if errors.Is(err, auth.ErrInvalidCredentials) { // Same message for unknown user and wrong password, as the web // login does, so usernames are not enumerable. httpx.WriteError(w, http.StatusUnauthorized, "invalid username or password") return } h.fail(w, "authenticate", err) return } label := deviceLabel(r) token, err := apitoken.Mint(r.Context(), h.db, u.ID, apitoken.KindJellyfin, label) if err != nil { h.fail(w, "mint token", err) return } dto, err := h.userDTO(r.Context(), u) if err != nil { h.fail(w, "build user", err) return } h.log.Info("jellyfin login", "user", u.Username, "device", label) httpx.WriteJSON(w, http.StatusOK, map[string]any{ "User": dto, "AccessToken": token, "ServerId": h.serverIDFor(r.Context()), }) } func (h *Handlers) handleUserMe(w http.ResponseWriter, r *http.Request, u auth.User) { dto, err := h.userDTO(r.Context(), u) if err != nil { h.fail(w, "build user", err) return } httpx.WriteJSON(w, http.StatusOK, dto) } func (h *Handlers) handleUserByID(w http.ResponseWriter, r *http.Request, u auth.User) { id, _, ok := dto.DecodeID(r.PathValue("userId")) if !ok || id != u.ID { // A token may only address its own user. Cross-user reads (which // Jellyfin permits for admins) are NOT implemented; a mismatched id is // a 404 so account existence is not disclosed. httpx.WriteError(w, http.StatusNotFound, "user not found") return } dto, err := h.userDTO(r.Context(), u) if err != nil { h.fail(w, "build user", err) return } httpx.WriteJSON(w, http.StatusOK, dto) } // userDTO renders the Jellyfin user shape, including the visible library // folders so a client can scope its views. func (h *Handlers) userDTO(ctx context.Context, u auth.User) (dto.UserDto, error) { libs, err := auth.VisibleLibraryIDs(ctx, h.db, u) if err != nil { return dto.UserDto{}, err } return dto.User(u, h.serverIDFor(ctx), libs), nil } // deviceLabel best-effort extracts a human device name from the // X-Emby-Authorization header so the token list in the UI is recognisable. func deviceLabel(r *http.Request) string { hdr := r.Header.Get("X-Emby-Authorization") if hdr == "" { hdr = r.Header.Get("Authorization") } for _, f := range splitParams(hdr) { if v, ok := cutParam(f, "Device="); ok && v != "" { return "Jellyfin: " + v } } return "Jellyfin" } // splitParams splits a MediaBrowser-style parameter list on commas or spaces. func splitParams(s string) []string { out := []string{} cur := "" for _, r := range s { if r == ',' || r == ' ' { if cur != "" { out = append(out, cur) } cur = "" continue } cur += string(r) } if cur != "" { out = append(out, cur) } return out } func cutParam(field, prefix string) (string, bool) { if len(field) < len(prefix) || field[:len(prefix)] != prefix { return "", false } v := field[len(prefix):] if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { v = v[1 : len(v)-1] } return v, true }