Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions pkg/sqlite/filter_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -640,3 +640,134 @@ func TestStringCriterionHandlerNotNull(t *testing.T) {
assert.Equal(fmt.Sprintf("(%[1]s IS NOT NULL AND TRIM(%[1]s) != '')", column), f.whereClauses[0].sql)
assert.Len(f.whereClauses[0].args, 0)
}

func TestValidateSortFavoritesFirstPrefix(t *testing.T) {
opts := sortOptions{"name", "rating", "random", "scenes_count"}
assert := assert.New(t)

// valid: known sorts with the favorites_first_ prefix pass
assert.NoError(opts.validateSort("favorites_first_name"))
assert.NoError(opts.validateSort("favorites_first_rating"))
assert.NoError(opts.validateSort("favorites_first_scenes_count"))

// valid: random with prefix passes (random itself is valid)
assert.NoError(opts.validateSort("favorites_first_random"))
assert.NoError(opts.validateSort("favorites_first_random_42"))

// invalid: prefixed random with invalid seed
assert.Error(opts.validateSort("favorites_first_random_not-a-number"))

// invalid: unknown sort after prefix
assert.Error(opts.validateSort("favorites_first_unknown_sort"))

// invalid: nothing after prefix
assert.Error(opts.validateSort("favorites_first_"))

// plain sorts still work unchanged
assert.NoError(opts.validateSort("name"))
assert.NoError(opts.validateSort("random_42"))
assert.Error(opts.validateSort("unknown"))
}

func TestGetPerformerSortFavoritesFirstPrefix(t *testing.T) {
qb := &PerformerStore{}
assert := assert.New(t)

favNameSort := "favorites_first_name"
favRatingSort := "favorites_first_rating"
favRandomSort := "favorites_first_random_42"
plainSort := "name"
invalidSort := "favorites_first_not_a_real_sort"

// favorites_first_name → ORDER BY performers.favorite DESC, performers.name …
sort, err := qb.getPerformerSort(&models.FindFilterType{Sort: &favNameSort})
assert.NoError(err)
assert.Contains(sort, "performers.favorite DESC")
assert.Contains(sort, "performers.name")

// favorites_first_rating → ORDER BY performers.favorite DESC, performers.rating …
sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &favRatingSort})
assert.NoError(err)
assert.Contains(sort, "performers.favorite DESC")
assert.Contains(sort, "performers.rating")

// favorites_first_random_<seed> keeps favorites first and randomizes within groups
sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &favRandomSort})
assert.NoError(err)
assert.Contains(sort, "performers.favorite DESC")
assert.Contains(sort, "mod((performers.id + 42)")

// plain name sort must NOT inject favorite
sort, err = qb.getPerformerSort(&models.FindFilterType{Sort: &plainSort})
assert.NoError(err)
assert.NotContains(sort, "favorite")

// unknown sort after prefix must error
_, err = qb.getPerformerSort(&models.FindFilterType{Sort: &invalidSort})
assert.Error(err)
}

func TestGetStudioSortFavoritesFirstPrefix(t *testing.T) {
qb := &StudioStore{}
assert := assert.New(t)

favNameSort := "favorites_first_name"
favRandomSort := "favorites_first_random_42"
plainSort := "name"
invalidSort := "favorites_first_not_a_real_sort"

// favorites_first_name → ORDER BY studios.favorite DESC, studios.name …
sort, err := qb.getStudioSort(&models.FindFilterType{Sort: &favNameSort})
assert.NoError(err)
assert.Contains(sort, "studios.favorite DESC")
assert.Contains(sort, "studios.name")

// favorites_first_random_<seed> keeps favorites first and randomizes within groups
sort, err = qb.getStudioSort(&models.FindFilterType{Sort: &favRandomSort})
assert.NoError(err)
assert.Contains(sort, "studios.favorite DESC")
assert.Contains(sort, "mod((studios.id + 42)")

// plain name sort must NOT inject favorite
sort, err = qb.getStudioSort(&models.FindFilterType{Sort: &plainSort})
assert.NoError(err)
assert.NotContains(sort, "favorite")

// unknown sort after prefix must error
_, err = qb.getStudioSort(&models.FindFilterType{Sort: &invalidSort})
assert.Error(err)
}

func TestGetTagSortFavoritesFirstPrefix(t *testing.T) {
qb := &TagStore{}
assert := assert.New(t)

favNameSort := "favorites_first_name"
favRandomSort := "favorites_first_random_42"
plainSort := "name"
invalidSort := "favorites_first_not_a_real_sort"

query := &queryBuilder{}

// favorites_first_name → ORDER BY tags.favorite DESC, COALESCE(tags.sort_name, tags.name) …
sort, err := qb.getTagSort(query, &models.FindFilterType{Sort: &favNameSort})
assert.NoError(err)
assert.Contains(sort, "tags.favorite DESC")
assert.Contains(sort, "tags.sort_name") // tag name uses COALESCE(sort_name, name)

// favorites_first_random_<seed> keeps favorites first and randomizes within groups
sort, err = qb.getTagSort(query, &models.FindFilterType{Sort: &favRandomSort})
assert.NoError(err)
assert.Contains(sort, "tags.favorite DESC")
assert.Contains(sort, "mod((tags.id + 42)")

// plain name sort must NOT inject favorite
sort, err = qb.getTagSort(query, &models.FindFilterType{Sort: &plainSort})
assert.NoError(err)
assert.NotContains(sort, "favorite")

// unknown sort after prefix must error
_, err = qb.getTagSort(query, &models.FindFilterType{Sort: &invalidSort})
assert.Error(err)
}

12 changes: 12 additions & 0 deletions pkg/sqlite/performer.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"slices"
"strings"

"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
Expand Down Expand Up @@ -842,6 +843,12 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s
direction = findFilter.GetDirection()
}

// check for favorites-first prefix and strip it before further processing
favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix)
if favoritesFirst {
sort = sort[len(FavoritesFirstPrefix):]
}

// CVE-2024-32231 - ensure sort is in the list of allowed sorts
if err := performerSortOptions.validateSort(sort); err != nil {
return "", err
Expand Down Expand Up @@ -877,6 +884,11 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s

// Whatever the sorting, always use name/id as a final sort
sortQuery += ", COALESCE(performers.name, performers.id) COLLATE NATURAL_CI ASC"

if favoritesFirst {
sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY performers.favorite DESC, ", 1)
}

return sortQuery, nil
}

Expand Down
9 changes: 8 additions & 1 deletion pkg/sqlite/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,17 @@ func getPaginationSQL(page int, perPage int) string {
return " LIMIT " + strconv.Itoa(perPage) + " OFFSET " + strconv.Itoa(page) + " "
}

const randomSeedPrefix = "random_" // prefix for random sort
const randomSeedPrefix = "random_" // prefix for random sort
const FavoritesFirstPrefix = "favorites_first_" // prefix for favorites-first sort

type sortOptions []string

func (o sortOptions) validateSort(sort string) error {
// strip favorites-first prefix before validating the underlying sort
if strings.HasPrefix(sort, FavoritesFirstPrefix) {
sort = sort[len(FavoritesFirstPrefix):]
}

if strings.HasPrefix(sort, randomSeedPrefix) {
// seed as a parameter from the UI
seedStr := sort[len(randomSeedPrefix):]
Expand All @@ -62,6 +68,7 @@ func (o sortOptions) validateSort(sort string) error {
return nil
}


for _, v := range o {
if v == sort {
return nil
Expand Down
12 changes: 12 additions & 0 deletions pkg/sqlite/studio.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"slices"
"strings"

"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
Expand Down Expand Up @@ -686,6 +687,12 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string,
direction = findFilter.GetDirection()
}

// check for favorites-first prefix and strip it before further processing
favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix)
if favoritesFirst {
sort = sort[len(FavoritesFirstPrefix):]
}

// CVE-2024-32231 - ensure sort is in the list of allowed sorts
if err := studioSortOptions.validateSort(sort); err != nil {
return "", err
Expand Down Expand Up @@ -715,6 +722,11 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string,

// Whatever the sorting, always use name/id as a final sort
sortQuery += ", COALESCE(studios.name, studios.id) COLLATE NATURAL_CI ASC"

if favoritesFirst {
sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY studios.favorite DESC, ", 1)
}

return sortQuery, nil
}

Expand Down
1 change: 1 addition & 0 deletions pkg/sqlite/studio_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1955,3 +1955,4 @@ func TestStudioQueryCustomFields(t *testing.T) {
// TODO All
// TODO AllSlim
// TODO Query

11 changes: 11 additions & 0 deletions pkg/sqlite/tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,12 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
direction = findFilter.GetDirection()
}

// check for favorites-first prefix and strip it before further processing
favoritesFirst := strings.HasPrefix(sort, FavoritesFirstPrefix)
if favoritesFirst {
sort = sort[len(FavoritesFirstPrefix):]
}

// CVE-2024-32231 - ensure sort is in the list of allowed sorts
if err := tagSortOptions.validateSort(sort); err != nil {
return "", err
Expand Down Expand Up @@ -844,6 +850,11 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte

// Whatever the sorting, always use sort_name/name/id as a final sort
sortQuery += ", COALESCE(tags.sort_name, tags.name, tags.id) COLLATE NATURAL_CI ASC"

if favoritesFirst {
sortQuery = strings.Replace(sortQuery, " ORDER BY ", " ORDER BY tags.favorite DESC, ", 1)
}

return sortQuery, nil
}

Expand Down
1 change: 1 addition & 0 deletions pkg/sqlite/tag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1958,3 +1958,4 @@ func TestTagQueryCustomFields(t *testing.T) {
// TODO All
// TODO AllSlim
// TODO Query

1 change: 1 addition & 0 deletions ui/v2.5/src/components/List/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum View {
TagScenes = "tag_scenes",
TagImages = "tag_images",
TagPerformers = "tag_performers",
TagStudios = "tag_studios",
TagGroups = "tag_groups",

PerformerScenes = "performer_scenes",
Expand Down
21 changes: 20 additions & 1 deletion ui/v2.5/src/components/Performers/PerformerList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ import { FavoritePerformerCriterionOption } from "src/models/list-filter/criteri
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import { SidebarOptionFilter } from "../List/Filters/OptionFilter";
import { GenderCriterionOption } from "src/models/list-filter/criteria/gender";
import { useConfigurationContext } from "src/hooks/Config";
import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook";
import { IUIConfig } from "src/core/config";

export const FormatHeight = (height?: number | null) => {
const intl = useIntl();
Expand Down Expand Up @@ -377,6 +380,22 @@ export const FilteredPerformerList = PatchComponent(
extraOperations = [],
} = props;

// favorites-first: applies on the main Performers page and related performer tabs
const { configuration } = useConfigurationContext();
const uiConfig = configuration?.ui as IUIConfig;
const favoritesFirstApplicable =
view === View.Performers ||
view === View.TagPerformers ||
view === View.StudioPerformers;
const showFavoritesFirst =
favoritesFirstApplicable &&
(uiConfig?.showFavoritesFirstPerformers ?? false);

const combinedFilterHook = useFavoritesFirstFilterHook(
showFavoritesFirst,
filterHook
);

// States
const {
showSidebar,
Expand All @@ -397,7 +416,7 @@ export const FilteredPerformerList = PatchComponent(
useResult: useFindPerformers,
getCount: (r) => r.data?.findPerformers.count ?? 0,
getItems: (r) => r.data?.findPerformers.performers ?? [],
filterHook,
filterHook: combinedFilterHook,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,39 @@ export const SettingsInterfacePanel: React.FC = PatchComponent(
/>
</SettingSection>

<SettingSection headingID="config.ui.favorites_first.heading">
<div className="setting-group">
<div className="setting">
<div>
<div className="sub-heading">
{intl.formatMessage({
id: "config.ui.favorites_first.description",
})}
</div>
</div>
<div />
</div>
<BooleanSetting
id="showFavoritesFirstPerformers"
headingID="performer"
checked={ui.showFavoritesFirstPerformers ?? undefined}
onChange={(v) => saveUI({ showFavoritesFirstPerformers: v })}
/>
<BooleanSetting
id="showFavoritesFirstStudios"
headingID="studio"
checked={ui.showFavoritesFirstStudios ?? undefined}
onChange={(v) => saveUI({ showFavoritesFirstStudios: v })}
/>
<BooleanSetting
id="showFavoritesFirstTags"
headingID="tag"
checked={ui.showFavoritesFirstTags ?? undefined}
onChange={(v) => saveUI({ showFavoritesFirstTags: v })}
/>
</div>
</SettingSection>

<SettingSection headingID="config.ui.editing.heading">
<div className="setting-group">
<div className="setting">
Expand Down
19 changes: 18 additions & 1 deletion ui/v2.5/src/components/Studios/StudioList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import { FavoriteStudioCriterionOption } from "src/models/list-filter/criteria/favorite";
import { Button } from "react-bootstrap";
import cx from "classnames";
import { useConfigurationContext } from "src/hooks/Config";
import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook";
import { IUIConfig } from "src/core/config";

const StudioList: React.FC<{
studios: GQL.StudioDataFragment[];
Expand Down Expand Up @@ -204,6 +207,20 @@ export const FilteredStudioList = PatchComponent(

const { filterHook, view, alterQuery, extraOperations = [] } = props;

// favorites-first: applies on the main Studios page and the Tag → Studios tab
const { configuration } = useConfigurationContext();
const uiConfig = configuration?.ui as IUIConfig;
const favoritesFirstApplicable =
view === View.Studios || view === View.TagStudios;
const showFavoritesFirst =
favoritesFirstApplicable &&
(uiConfig?.showFavoritesFirstStudios ?? false);

const combinedFilterHook = useFavoritesFirstFilterHook(
showFavoritesFirst,
filterHook
);

// States
const {
showSidebar,
Expand All @@ -224,7 +241,7 @@ export const FilteredStudioList = PatchComponent(
useResult: useFindStudios,
getCount: (r) => r.data?.findStudios.count ?? 0,
getItems: (r) => r.data?.findStudios.studios ?? [],
filterHook,
filterHook: combinedFilterHook,
},
});

Expand Down
Loading