mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-25 10:09:03 +01:00
feat: add ability to search and select icons from selfh.st (#1807)
This commit is contained in:
@@ -276,6 +276,14 @@ func LogoTooLarge(maxSize string) *Error {
|
||||
WithDetail("max_size", maxSize)
|
||||
}
|
||||
|
||||
func LogoPresetsDisabled() *Error {
|
||||
return New(CodeLogoPresetsDisabled, http.StatusForbidden, "Logo presets are disabled")
|
||||
}
|
||||
|
||||
func LogoPresetsUnavailable(cause error) *Error {
|
||||
return Wrap(cause, CodeLogoPresetsUnavailable, http.StatusBadGateway, "Logo presets could not be loaded")
|
||||
}
|
||||
|
||||
func InvalidLogoURL(cause error) *Error {
|
||||
return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{
|
||||
Field: "logoUrl",
|
||||
|
||||
@@ -67,6 +67,8 @@ const (
|
||||
CodeLogoDownloadFailed Code = "logo_download_failed"
|
||||
CodeLogoTypeNotSupported Code = "logo_type_not_supported"
|
||||
CodeLogoTooLarge Code = "logo_too_large"
|
||||
CodeLogoPresetsDisabled Code = "logo_presets_disabled"
|
||||
CodeLogoPresetsUnavailable Code = "logo_presets_unavailable"
|
||||
CodeOidcPARRequired Code = "oidc_par_required"
|
||||
)
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices
|
||||
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
svc.environmentModule.RegisterRoutes(apiGroup, authMiddleware.WithAdminNotRequired().Add())
|
||||
svc.logoPresetModule.RegisterRoutes(apiGroup, authMiddleware.Add())
|
||||
svc.scimSyncModule.RegisterRoutes(apiGroup, authMiddleware.Add())
|
||||
svc.userSignUpModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.Add(),
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/geolite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/iplocation"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/ldapsync"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/logopreset"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/onetimeaccess"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/scimsync"
|
||||
@@ -55,6 +56,7 @@ type services struct {
|
||||
emailVerificationModule *emailverification.Module
|
||||
apiModule *api.Module
|
||||
environmentModule *environment.Module
|
||||
logoPresetModule *logopreset.Module
|
||||
actors francishost.Host
|
||||
}
|
||||
|
||||
@@ -258,6 +260,16 @@ func initServices(
|
||||
SQLiteOnNetworkedFilesystem: sqliteOnNetworkedFilesystem,
|
||||
})
|
||||
|
||||
// An empty base URL keeps the icon library disabled
|
||||
var iconLibraryURL string
|
||||
if common.EnvConfig.IconLibraryEnabled() {
|
||||
iconLibraryURL = common.EnvConfig.IconLibraryURL
|
||||
}
|
||||
svc.logoPresetModule = logopreset.New(logopreset.Dependencies{
|
||||
HTTPClient: httpClient,
|
||||
BaseURL: iconLibraryURL,
|
||||
})
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -44,6 +45,11 @@ const (
|
||||
defaultFsUploadPath string = "data/uploads"
|
||||
AppUrl string = "http://localhost:1411"
|
||||
|
||||
// IconLibraryDisabled is the ICON_LIBRARY_URL value that turns off the icon library for OIDC client logos
|
||||
IconLibraryDisabled string = "disabled"
|
||||
// DefaultIconLibraryURL serves the selfh.st icon collection through jsDelivr
|
||||
DefaultIconLibraryURL string = "https://cdn.jsdelivr.net/gh/selfhst/icons@main"
|
||||
|
||||
// FrancisHostEmbedded is the FRANCIS_HOST value that keeps the Francis actor runtime embedded in the Pocket ID process
|
||||
FrancisHostEmbedded string = "embedded"
|
||||
// francisHostPSKMinLength is the min length for the host bootstrap pre-shared key for Francis
|
||||
@@ -98,6 +104,11 @@ type EnvConfigSchema struct {
|
||||
// CloudflareLocationHeaders trusts location headers supplied by a Cloudflare proxy instead of using GeoLite
|
||||
CloudflareLocationHeaders bool `env:"CLOUDFLARE_LOCATION_HEADERS"`
|
||||
|
||||
// IconLibraryURL is the base URL of the icon collection admins can pick OIDC client logos from
|
||||
// It must serve index.json and the svg and png directories in the same layout as the selfh.st/icons repository
|
||||
// Setting it to "disabled" turns the icon library off, so Pocket ID never contacts it
|
||||
IconLibraryURL string `env:"ICON_LIBRARY_URL" options:"trimTrailingSlash"`
|
||||
|
||||
ActorsPort string `env:"ACTORS_PORT"`
|
||||
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
|
||||
|
||||
@@ -166,6 +177,7 @@ func defaultConfig() EnvConfigSchema {
|
||||
FrancisHost: FrancisHostEmbedded,
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
IconLibraryURL: DefaultIconLibraryURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +261,11 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateIconLibraryURL(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateLocalIPv6Ranges(config.LocalIPv6Ranges)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -391,6 +408,48 @@ func validateURLWithoutPath(rawURL, envName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateIconLibraryURL checks that ICON_LIBRARY_URL is either "disabled" or an absolute HTTP(S) URL
|
||||
func validateIconLibraryURL(config *EnvConfigSchema) error {
|
||||
switch {
|
||||
case config.IconLibraryURL == "":
|
||||
config.IconLibraryURL = DefaultIconLibraryURL
|
||||
return nil
|
||||
case strings.EqualFold(config.IconLibraryURL, IconLibraryDisabled):
|
||||
config.IconLibraryURL = IconLibraryDisabled
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(config.IconLibraryURL)
|
||||
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" {
|
||||
return errors.New("ICON_LIBRARY_URL must be an HTTP or HTTPS URL, or 'disabled'")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IconLibraryEnabled reports whether admins can pick OIDC client logos from the icon library
|
||||
func (c *EnvConfigSchema) IconLibraryEnabled() bool {
|
||||
return c.IconLibraryURL != IconLibraryDisabled
|
||||
}
|
||||
|
||||
// IsIconLibraryURL reports whether the URL points to a file inside the configured icon library
|
||||
// Dot segments are rejected so a URL can't climb out of the library's path on the same host
|
||||
func (c *EnvConfigSchema) IsIconLibraryURL(u *url.URL) bool {
|
||||
if !c.IconLibraryEnabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
base, err := url.Parse(c.IconLibraryURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return u.Scheme == base.Scheme &&
|
||||
strings.EqualFold(u.Host, base.Host) &&
|
||||
u.Path == path.Clean(u.Path) &&
|
||||
strings.HasPrefix(u.Path, base.Path+"/")
|
||||
}
|
||||
|
||||
func validateFileBackend(config *EnvConfigSchema) error {
|
||||
switch config.FileBackend {
|
||||
case "s3", "database":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
@@ -657,3 +658,79 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
assert.Equal(t, keyContent, EnvConfig.TLSKey)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIconLibraryConfig(t *testing.T) {
|
||||
originalConfig := EnvConfig
|
||||
t.Cleanup(func() {
|
||||
EnvConfig = originalConfig
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value *string
|
||||
wantURL string
|
||||
wantEnabled bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "defaults to the selfh.st collection", wantURL: DefaultIconLibraryURL, wantEnabled: true},
|
||||
{name: "empty value falls back to the default", value: new(""), wantURL: DefaultIconLibraryURL, wantEnabled: true},
|
||||
{name: "custom URL without the trailing slash", value: new("http://192.168.1.10:4050/icons/"), wantURL: "http://192.168.1.10:4050/icons", wantEnabled: true},
|
||||
{name: "disabled is case-insensitive", value: new("Disabled"), wantURL: IconLibraryDisabled, wantEnabled: false},
|
||||
{name: "rejects other schemes", value: new("ftp://mirror.example.com/icons"), wantErr: "ICON_LIBRARY_URL must be an HTTP or HTTPS URL"},
|
||||
{name: "rejects values without a host", value: new("icons"), wantErr: "ICON_LIBRARY_URL must be an HTTP or HTTPS URL"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
if tt.value != nil {
|
||||
t.Setenv("ICON_LIBRARY_URL", *tt.value)
|
||||
}
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
if tt.wantErr != "" {
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantURL, EnvConfig.IconLibraryURL)
|
||||
assert.Equal(t, tt.wantEnabled, EnvConfig.IconLibraryEnabled())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIconLibraryURL(t *testing.T) {
|
||||
config := EnvConfigSchema{IconLibraryURL: "http://mirror.lan:4050/icons"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{name: "file inside the library", url: "http://mirror.lan:4050/icons/svg/nextcloud.svg", want: true},
|
||||
{name: "host is matched case-insensitively", url: "http://MIRROR.lan:4050/icons/svg/nextcloud.svg", want: true},
|
||||
{name: "path outside the library", url: "http://mirror.lan:4050/admin/logo.svg", want: false},
|
||||
{name: "path sharing the library prefix", url: "http://mirror.lan:4050/icons-private/logo.svg", want: false},
|
||||
{name: "dot segments climbing out of the library", url: "http://mirror.lan:4050/icons/../admin/logo.svg", want: false},
|
||||
{name: "encoded dot segments", url: "http://mirror.lan:4050/icons/%2e%2e/admin/logo.svg", want: false},
|
||||
{name: "host that starts with the library host", url: "http://mirror.lan.evil.com:4050/icons/svg/nextcloud.svg", want: false},
|
||||
{name: "different port", url: "http://mirror.lan:8080/icons/svg/nextcloud.svg", want: false},
|
||||
{name: "different scheme", url: "https://mirror.lan:4050/icons/svg/nextcloud.svg", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, err := url.Parse(tt.url)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, config.IsIconLibraryURL(u))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nothing is inside a disabled library", func(t *testing.T) {
|
||||
disabled := EnvConfigSchema{IconLibraryURL: IconLibraryDisabled}
|
||||
u, err := url.Parse("http://disabled/svg/nextcloud.svg")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, disabled.IsIconLibraryURL(u))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,10 +79,28 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) error {
|
||||
Value: strconv.FormatBool(tracing.FrontendTracingEnabled()),
|
||||
})
|
||||
|
||||
// Manually add iconLibrary, derived from ICON_LIBRARY_URL, so the frontend can hide the icon picker and only credits selfh.st when its collection is used
|
||||
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
|
||||
Key: "iconLibrary",
|
||||
Value: iconLibraryStatus(),
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
return nil
|
||||
}
|
||||
|
||||
// iconLibraryStatus reports whether the icon library is disabled, uses the default selfh.st collection, or points to a custom source
|
||||
func iconLibraryStatus() string {
|
||||
switch common.EnvConfig.IconLibraryURL {
|
||||
case common.IconLibraryDisabled:
|
||||
return "disabled"
|
||||
case common.DefaultIconLibraryURL:
|
||||
return "default"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
// listAllAppConfigHandler godoc
|
||||
// @Summary List all application configurations
|
||||
// @Description Get all application configurations including private ones
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package logopreset
|
||||
|
||||
type logoPresetDto struct {
|
||||
Name string `json:"name"`
|
||||
Reference string `json:"reference"`
|
||||
LogoURL string `json:"logoUrl"`
|
||||
DarkLogoURL *string `json:"darkLogoUrl"`
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package logopreset
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
_ "github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// search godoc
|
||||
// @Summary Search logo presets
|
||||
// @Description Search the icon library for logos that can be used for OIDC clients
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param search query string false "Search term matched against the icon name"
|
||||
// @Success 200 {array} logoPresetDto
|
||||
// @Failure default {object} dto.ErrorDto "Error"
|
||||
// @Router /api/oidc/logo-presets [get]
|
||||
func (h *handler) search(c *gin.Context) error {
|
||||
presets, err := h.service.Search(c.Request.Context(), c.Query("search"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, presets)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package logopreset
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
HTTPClient *http.Client
|
||||
|
||||
// BaseURL is the root of the icon library, or empty when the icon library is disabled
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
// Module lets admins search an icon collection in the selfh.st/icons layout for OIDC client logos
|
||||
// The chosen icon is downloaded through the regular logo URL flow of the OIDC client update
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) *Module {
|
||||
service := newService(deps)
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the logo preset endpoints
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth gin.HandlerFunc) {
|
||||
apiGroup.GET("/oidc/logo-presets", auth, httpserver.Handle(m.handler.search))
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package logopreset
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
indexTTL = 24 * time.Hour
|
||||
fetchTimeout = 10 * time.Second
|
||||
maxIndexSize = 5 << 20
|
||||
maxResults = 30
|
||||
)
|
||||
|
||||
// referencePattern matches the slugs selfh.st uses as file names, so a reference can never escape its directory in an icon URL
|
||||
var referencePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||
|
||||
// nonAlphanumeric is stripped from names and queries so "home assistant" matches "Home-Assistant"
|
||||
var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// indexEntry is one icon in selfh.st's index.json, whose availability flags are the strings "Yes" or "No"
|
||||
type indexEntry struct {
|
||||
Name string `json:"Name"`
|
||||
Reference string `json:"Reference"`
|
||||
SVG string `json:"SVG"`
|
||||
Light string `json:"Light"`
|
||||
Tags string `json:"Tags"`
|
||||
}
|
||||
|
||||
// preset is an index entry with its search keys normalized once when the index is loaded
|
||||
type preset struct {
|
||||
indexEntry
|
||||
|
||||
searchName string
|
||||
searchReference string
|
||||
searchTags string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
cache *utils.Cache[[]preset]
|
||||
}
|
||||
|
||||
func newService(deps Dependencies) *Service {
|
||||
return &Service{
|
||||
httpClient: deps.HTTPClient,
|
||||
baseURL: deps.BaseURL,
|
||||
cache: utils.New[[]preset](indexTTL),
|
||||
}
|
||||
}
|
||||
|
||||
// Search returns the icons that best match the query, ranked from exact to loose matches
|
||||
func (s *Service) Search(ctx context.Context, query string) ([]logoPresetDto, error) {
|
||||
// Operators can turn the icon library off so Pocket ID never contacts it
|
||||
if s.baseURL == "" {
|
||||
return nil, apperror.LogoPresetsDisabled()
|
||||
}
|
||||
|
||||
presets, err := s.getIndex(ctx)
|
||||
if err != nil {
|
||||
return nil, apperror.LogoPresetsUnavailable(err)
|
||||
}
|
||||
|
||||
matches := search(presets, query, maxResults)
|
||||
result := make([]logoPresetDto, len(matches))
|
||||
for i, p := range matches {
|
||||
result[i] = s.toDto(p)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) getIndex(ctx context.Context) ([]preset, error) {
|
||||
presets, err := s.cache.GetOrFetch(ctx, s.fetchIndex)
|
||||
|
||||
// A stale index is still good enough to search while the CDN is unreachable
|
||||
if staleErr, ok := errors.AsType[*utils.ErrStale](err); ok {
|
||||
slog.WarnContext(ctx, "Failed to refresh logo preset index, using stale cache", slog.Any("error", staleErr.Err))
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
return presets, err
|
||||
}
|
||||
|
||||
func (s *Service) fetchIndex(ctx context.Context) ([]preset, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Download the index that lists every icon in the collection
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, s.baseURL+"/index.json", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create icon index request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "pocket-id/logo-presets")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch icon index: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("icon index returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var entries []indexEntry
|
||||
err = json.NewDecoder(utils.NewLimitReader(resp.Body, maxIndexSize)).Decode(&entries)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode icon index: %w", err)
|
||||
}
|
||||
|
||||
// Skip entries whose reference can't safely be used as a file name in an icon URL
|
||||
presets := make([]preset, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !referencePattern.MatchString(e.Reference) {
|
||||
continue
|
||||
}
|
||||
|
||||
presets = append(presets, preset{
|
||||
indexEntry: e,
|
||||
searchName: normalize(e.Name),
|
||||
searchReference: normalize(e.Reference),
|
||||
searchTags: strings.ToLower(e.Tags),
|
||||
})
|
||||
}
|
||||
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// search returns up to limit presets matching the query, ordered by match quality and then by name
|
||||
func search(presets []preset, query string, limit int) []preset {
|
||||
normalizedQuery := normalize(query)
|
||||
tagQuery := strings.ToLower(strings.TrimSpace(query))
|
||||
|
||||
type match struct {
|
||||
preset preset
|
||||
rank int
|
||||
}
|
||||
|
||||
// Rank every preset that matches the query at all
|
||||
matches := make([]match, 0, limit)
|
||||
for _, p := range presets {
|
||||
rank, ok := matchRank(p, normalizedQuery, tagQuery)
|
||||
if ok {
|
||||
matches = append(matches, match{preset: p, rank: rank})
|
||||
}
|
||||
}
|
||||
|
||||
// Lower ranks are better matches, and ties are broken alphabetically so results are stable
|
||||
slices.SortFunc(matches, func(a, b match) int {
|
||||
return cmp.Or(
|
||||
cmp.Compare(a.rank, b.rank),
|
||||
cmp.Compare(a.preset.searchName, b.preset.searchName),
|
||||
)
|
||||
})
|
||||
|
||||
result := make([]preset, 0, min(limit, len(matches)))
|
||||
for _, m := range matches[:min(limit, len(matches))] {
|
||||
result = append(result, m.preset)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// matchRank reports how well a preset matches the query, where 0 is an exact match
|
||||
func matchRank(p preset, normalizedQuery, tagQuery string) (int, bool) {
|
||||
switch {
|
||||
case normalizedQuery == "":
|
||||
return 0, true
|
||||
case p.searchName == normalizedQuery || p.searchReference == normalizedQuery:
|
||||
return 0, true
|
||||
case strings.HasPrefix(p.searchName, normalizedQuery) || strings.HasPrefix(p.searchReference, normalizedQuery):
|
||||
return 1, true
|
||||
case strings.Contains(p.searchName, normalizedQuery) || strings.Contains(p.searchReference, normalizedQuery):
|
||||
return 2, true
|
||||
case tagQuery != "" && strings.Contains(p.searchTags, tagQuery):
|
||||
return 3, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) toDto(p preset) logoPresetDto {
|
||||
format := "png"
|
||||
if p.SVG == "Yes" {
|
||||
format = "svg"
|
||||
}
|
||||
|
||||
dto := logoPresetDto{
|
||||
Name: p.Name,
|
||||
Reference: p.Reference,
|
||||
LogoURL: s.iconURL(format, p.Reference),
|
||||
}
|
||||
|
||||
// The white variant keeps monochrome logos readable on dark backgrounds
|
||||
if p.Light == "Yes" {
|
||||
darkLogoURL := s.iconURL(format, p.Reference+"-light")
|
||||
dto.DarkLogoURL = &darkLogoURL
|
||||
}
|
||||
|
||||
return dto
|
||||
}
|
||||
|
||||
func (s *Service) iconURL(format, name string) string {
|
||||
return s.baseURL + "/" + format + "/" + name + "." + format
|
||||
}
|
||||
|
||||
func normalize(s string) string {
|
||||
return nonAlphanumeric.ReplaceAllString(strings.ToLower(s), "")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package logopreset
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
const testBaseURL = "https://icons.example.com"
|
||||
|
||||
const testIndex = `[
|
||||
{"Name": "Nextcloud", "Reference": "nextcloud", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Cloud Storage"},
|
||||
{"Name": "Nextcloud Talk", "Reference": "nextcloud-talk", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""},
|
||||
{"Name": "Ghost", "Reference": "ghost", "SVG": "No", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": "Blogging"},
|
||||
{"Name": "Home Assistant", "Reference": "home-assistant", "SVG": "Yes", "PNG": "Yes", "Light": "Yes", "Dark": "Yes", "Tags": ""},
|
||||
{"Name": "OwnCloud", "Reference": "owncloud", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": "Cloud Storage"},
|
||||
{"Name": "Bad", "Reference": "../bad", "SVG": "Yes", "PNG": "Yes", "Light": "No", "Dark": "No", "Tags": ""}
|
||||
]`
|
||||
|
||||
// countingRoundTripper counts requests before delegating to the mock, so tests can assert when the index is fetched
|
||||
type countingRoundTripper struct {
|
||||
next http.RoundTripper
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (c *countingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
c.calls.Add(1)
|
||||
return c.next.RoundTrip(req)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, mock *testutils.MockRoundTripper) (*Service, *countingRoundTripper) {
|
||||
t.Helper()
|
||||
|
||||
transport := &countingRoundTripper{next: mock}
|
||||
return newService(Dependencies{HTTPClient: &http.Client{Transport: transport}, BaseURL: testBaseURL}), transport
|
||||
}
|
||||
|
||||
func indexMock() *testutils.MockRoundTripper {
|
||||
return &testutils.MockRoundTripper{
|
||||
Responses: map[string]*http.Response{
|
||||
testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusOK, testIndex), //nolint:bodyclose // mock response, no real body
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func references(presets []logoPresetDto) []string {
|
||||
refs := make([]string, len(presets))
|
||||
for i, p := range presets {
|
||||
refs[i] = p.Reference
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func TestSearch_Disabled(t *testing.T) {
|
||||
svc, transport := newTestService(t, indexMock())
|
||||
svc.baseURL = ""
|
||||
|
||||
_, err := svc.Search(t.Context(), "nextcloud")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsDisabled))
|
||||
assert.Zero(t, transport.calls.Load(), "a disabled icon library must not be contacted")
|
||||
}
|
||||
|
||||
func TestSearch_Ranking(t *testing.T) {
|
||||
svc, _ := newTestService(t, indexMock())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
{name: "exact match ranks before prefix match", query: "nextcloud", want: []string{"nextcloud", "nextcloud-talk"}},
|
||||
{name: "substring matches come after prefix matches", query: "cloud", want: []string{"nextcloud", "nextcloud-talk", "owncloud"}},
|
||||
{name: "separators and case are ignored", query: "HOME assistant", want: []string{"home-assistant"}},
|
||||
{name: "tags match last", query: "blogging", want: []string{"ghost"}},
|
||||
{name: "empty query lists everything alphabetically", query: "", want: []string{"ghost", "home-assistant", "nextcloud", "nextcloud-talk", "owncloud"}},
|
||||
{name: "no match", query: "jellyfin", want: []string{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
presets, err := svc.Search(t.Context(), tt.query)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, references(presets))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_LimitsResults(t *testing.T) {
|
||||
presets := make([]preset, maxResults+10)
|
||||
for i := range presets {
|
||||
presets[i] = preset{searchName: "app", searchReference: "app"}
|
||||
}
|
||||
|
||||
assert.Len(t, search(presets, "app", maxResults), maxResults)
|
||||
}
|
||||
|
||||
func TestSearch_BuildsIconURLs(t *testing.T) {
|
||||
svc, _ := newTestService(t, indexMock())
|
||||
|
||||
presets, err := svc.Search(t.Context(), "")
|
||||
require.NoError(t, err)
|
||||
|
||||
byReference := make(map[string]logoPresetDto, len(presets))
|
||||
for _, p := range presets {
|
||||
byReference[p.Reference] = p
|
||||
}
|
||||
|
||||
// SVG icon with a white variant for dark mode
|
||||
nextcloud := byReference["nextcloud"]
|
||||
assert.Equal(t, testBaseURL+"/svg/nextcloud.svg", nextcloud.LogoURL)
|
||||
require.NotNil(t, nextcloud.DarkLogoURL)
|
||||
assert.Equal(t, testBaseURL+"/svg/nextcloud-light.svg", *nextcloud.DarkLogoURL)
|
||||
|
||||
// SVG icon without a white variant leaves the dark logo empty
|
||||
talk := byReference["nextcloud-talk"]
|
||||
assert.Equal(t, testBaseURL+"/svg/nextcloud-talk.svg", talk.LogoURL)
|
||||
assert.Nil(t, talk.DarkLogoURL)
|
||||
|
||||
// PNG-only icon falls back to PNG for both variants
|
||||
ghost := byReference["ghost"]
|
||||
assert.Equal(t, testBaseURL+"/png/ghost.png", ghost.LogoURL)
|
||||
require.NotNil(t, ghost.DarkLogoURL)
|
||||
assert.Equal(t, testBaseURL+"/png/ghost-light.png", *ghost.DarkLogoURL)
|
||||
|
||||
// References that aren't plain slugs are dropped
|
||||
assert.NotContains(t, byReference, "../bad")
|
||||
}
|
||||
|
||||
func TestSearch_CachesIndex(t *testing.T) {
|
||||
svc, transport := newTestService(t, indexMock())
|
||||
|
||||
for range 3 {
|
||||
_, err := svc.Search(t.Context(), "nextcloud")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(1), transport.calls.Load())
|
||||
}
|
||||
|
||||
func TestSearch_UsesStaleIndexWhenRefreshFails(t *testing.T) {
|
||||
mock := indexMock()
|
||||
svc, _ := newTestService(t, mock)
|
||||
|
||||
// Expire the cache immediately so the second search has to refetch
|
||||
svc.cache = utils.New[[]preset](time.Nanosecond)
|
||||
|
||||
_, err := svc.Search(t.Context(), "nextcloud")
|
||||
require.NoError(t, err)
|
||||
|
||||
mock.Err = errors.New("CDN unreachable")
|
||||
|
||||
presets, err := svc.Search(t.Context(), "nextcloud")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"nextcloud", "nextcloud-talk"}, references(presets))
|
||||
}
|
||||
|
||||
func TestSearch_IndexUnavailable(t *testing.T) {
|
||||
mock := &testutils.MockRoundTripper{
|
||||
Responses: map[string]*http.Response{
|
||||
testBaseURL + "/index.json": testutils.NewMockResponse(http.StatusInternalServerError, ""), //nolint:bodyclose // mock response, no real body
|
||||
},
|
||||
}
|
||||
svc, _ := newTestService(t, mock)
|
||||
|
||||
_, err := svc.Search(t.Context(), "nextcloud")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, apperror.IsCode(err, apperror.CodeLogoPresetsUnavailable))
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/backchannellogout"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -522,15 +523,14 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
// Each variant falls back to the other one, so a client with a single logo shows it in both themes
|
||||
var suffix string
|
||||
var ext string
|
||||
switch {
|
||||
case !light && client.DarkImageType != nil:
|
||||
// Dark logo if requested and exists
|
||||
case client.HasDarkLogo() && (!light || !client.HasLogo()):
|
||||
suffix = "-dark"
|
||||
ext = *client.DarkImageType
|
||||
case client.ImageType != nil:
|
||||
// Light logo if requested or no dark logo is available
|
||||
case client.HasLogo():
|
||||
ext = *client.ImageType
|
||||
default:
|
||||
return nil, 0, "", apperror.ImageNotFound()
|
||||
@@ -943,6 +943,23 @@ func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *ht
|
||||
return client
|
||||
}
|
||||
|
||||
// checkLogoURLAllowed prevents SSRF by allowing only URLs that resolve to public IPs
|
||||
// URLs inside the icon library are exempt because the operator configured it, which lets a self-hosted mirror live on the local network
|
||||
func checkLogoURLAllowed(ctx context.Context, u *url.URL) error {
|
||||
if common.EnvConfig.IsIconLibraryURL(u) {
|
||||
return nil
|
||||
}
|
||||
|
||||
private, err := utils.IsURLPrivate(ctx, u)
|
||||
if err != nil {
|
||||
return apperror.LogoDownloadFailed(err)
|
||||
} else if private {
|
||||
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
@@ -955,12 +972,9 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Prevents SSRF by allowing only public IPs
|
||||
ok, err := utils.IsURLPrivate(ctx, u)
|
||||
err = checkLogoURLAllowed(ctx, u)
|
||||
if err != nil {
|
||||
return apperror.LogoDownloadFailed(err)
|
||||
} else if ok {
|
||||
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
|
||||
return err
|
||||
}
|
||||
|
||||
// We need to check this on redirects too
|
||||
@@ -969,14 +983,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
|
||||
return apperror.InvalidLogoURL(errors.New("stopped after 10 redirects"))
|
||||
}
|
||||
|
||||
ok, err := utils.IsURLPrivate(r.Context(), r.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
return apperror.InvalidLogoURL(errors.New("private IP addresses are not allowed"))
|
||||
}
|
||||
|
||||
return nil
|
||||
return checkLogoURLAllowed(r.Context(), r.URL)
|
||||
})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -413,6 +414,41 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
})
|
||||
|
||||
t.Run("Allows private hosts inside the icon library only", func(t *testing.T) {
|
||||
const iconLibraryURL = "http://127.0.0.1:4050/icons"
|
||||
originalIconLibraryURL := common.EnvConfig.IconLibraryURL
|
||||
common.EnvConfig.IconLibraryURL = iconLibraryURL
|
||||
t.Cleanup(func() {
|
||||
common.EnvConfig.IconLibraryURL = originalIconLibraryURL
|
||||
})
|
||||
|
||||
//nolint:bodyclose
|
||||
svgResponse := testutils.NewMockResponse(http.StatusOK, `<svg xmlns="http://www.w3.org/2000/svg"/>`)
|
||||
svgResponse.Header.Set("Content-Type", "image/svg+xml")
|
||||
|
||||
s := &OidcService{
|
||||
db: db,
|
||||
fileStorage: dbStorage,
|
||||
httpClient: &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
Responses: map[string]*http.Response{
|
||||
iconLibraryURL + "/svg/nextcloud.svg": svgResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// The operator configured the library, so its loopback address is trusted
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, iconLibraryURL+"/svg/nextcloud.svg", true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, fileExists(t, "oidc-client-images/"+client.ID+".svg"))
|
||||
|
||||
// Other paths on the same private host are still blocked
|
||||
err = s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "http://127.0.0.1:4050/admin/logo.svg", true)
|
||||
require.Error(t, err)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeValidationFailed))
|
||||
})
|
||||
|
||||
t.Run("Returns error for non-200 status code", func(t *testing.T) {
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
@@ -1074,3 +1110,69 @@ func accessibleClientNames(clients []dto.AccessibleOidcClientDto) []string {
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestOidcService_GetClientLogo(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
dbStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
s := &OidcService{db: db, fileStorage: dbStorage}
|
||||
|
||||
// createClient stores the given variants with their name as content, so the test can tell which one was served
|
||||
createClient := func(t *testing.T, light, dark bool) string {
|
||||
t.Helper()
|
||||
|
||||
client := model.OidcClient{Name: "Logo Client", CallbackURLs: datatype.StringList{"https://example.com/callback"}}
|
||||
if light {
|
||||
client.ImageType = new("png")
|
||||
}
|
||||
if dark {
|
||||
client.DarkImageType = new("png")
|
||||
}
|
||||
require.NoError(t, db.Create(&client).Error)
|
||||
|
||||
if light {
|
||||
require.NoError(t, dbStorage.Save(t.Context(), oidcClientImagePath(client.ID, "", "png"), strings.NewReader("light")))
|
||||
}
|
||||
if dark {
|
||||
require.NoError(t, dbStorage.Save(t.Context(), oidcClientImagePath(client.ID, "-dark", "png"), strings.NewReader("dark")))
|
||||
}
|
||||
return client.ID
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
light bool
|
||||
dark bool
|
||||
wantLight string
|
||||
wantDark string
|
||||
}{
|
||||
{name: "serves each variant when both exist", light: true, dark: true, wantLight: "light", wantDark: "dark"},
|
||||
{name: "dark mode falls back to the light logo", light: true, wantLight: "light", wantDark: "light"},
|
||||
{name: "light mode falls back to the dark logo", dark: true, wantLight: "dark", wantDark: "dark"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clientID := createClient(t, tt.light, tt.dark)
|
||||
|
||||
for requestLight, want := range map[bool]string{true: tt.wantLight, false: tt.wantDark} {
|
||||
reader, _, mimeType, err := s.GetClientLogo(t.Context(), clientID, requestLight)
|
||||
require.NoError(t, err)
|
||||
content, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want, string(content), "light=%t", requestLight)
|
||||
assert.Equal(t, "image/png", mimeType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("returns not found without any logo", func(t *testing.T) {
|
||||
clientID := createClient(t, false, false)
|
||||
|
||||
for _, requestLight := range []bool{true, false} {
|
||||
_, _, _, err := s.GetClientLogo(t.Context(), clientID, requestLight)
|
||||
require.True(t, apperror.IsCode(err, apperror.CodeImageNotFound), "light=%t", requestLight)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user