This commit is contained in:
floyd352 2026-03-24 01:28:39 -04:00 committed by GitHub
commit 32372b1340
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 325 additions and 5 deletions

View file

@ -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)
}

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"slices"
"strings"
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
@ -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
@ -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
}

View file

@ -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):]
@ -62,6 +68,7 @@ func (o sortOptions) validateSort(sort string) error {
return nil
}
for _, v := range o {
if v == sort {
return nil

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"slices"
"strings"
"github.com/doug-martin/goqu/v9"
"github.com/doug-martin/goqu/v9/exp"
@ -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
@ -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
}

View file

@ -1955,3 +1955,4 @@ func TestStudioQueryCustomFields(t *testing.T) {
// TODO All
// TODO AllSlim
// TODO Query

View file

@ -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
@ -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
}

View file

@ -1958,3 +1958,4 @@ func TestTagQueryCustomFields(t *testing.T) {
// TODO All
// TODO AllSlim
// TODO Query

View file

@ -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",

View file

@ -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();
@ -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,
@ -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,
},
});

View file

@ -702,6 +702,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">

View file

@ -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[];
@ -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,
@ -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,
},
});

View file

@ -2,6 +2,7 @@ import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useTagFilterHook } from "src/core/tags";
import { FilteredStudioList } from "src/components/Studios/StudioList";
import { View } from "src/components/List/views";
interface ITagStudiosPanel {
active: boolean;
@ -15,5 +16,11 @@ export const TagStudiosPanel: React.FC<ITagStudiosPanel> = ({
showSubTagContent,
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return <FilteredStudioList filterHook={filterHook} alterQuery={active} />;
return (
<FilteredStudioList
filterHook={filterHook}
alterQuery={active}
view={View.TagStudios}
/>
);
};

View file

@ -47,6 +47,9 @@ import { LoadedContent } from "../List/PagedList";
import { SidebarBooleanFilter } from "../List/Filters/BooleanFilter";
import { FavoriteTagCriterionOption } from "src/models/list-filter/criteria/favorite";
import { TagListTable } from "./TagListTable";
import { useConfigurationContext } from "src/hooks/Config";
import { useFavoritesFirstFilterHook } from "src/hooks/useFavoritesFirstFilterHook";
import { IUIConfig } from "src/core/config";
const TagList: React.FC<{
tags: GQL.TagListDataFragment[];
@ -206,6 +209,16 @@ export const FilteredTagList = PatchComponent(
const view = View.Tags;
// favorites-first: applies on the main Tags page
const { configuration } = useConfigurationContext();
const uiConfig = configuration?.ui as IUIConfig;
const showFavoritesFirst = uiConfig?.showFavoritesFirstTags ?? false;
const combinedFilterHook = useFavoritesFirstFilterHook(
showFavoritesFirst,
filterHook
);
// States
const {
showSidebar,
@ -226,7 +239,7 @@ export const FilteredTagList = PatchComponent(
useResult: useFindTagsForList,
getCount: (r) => r.data?.findTags.count ?? 0,
getItems: (r) => r.data?.findTags.tags ?? [],
filterHook,
filterHook: combinedFilterHook,
},
});

View file

@ -70,6 +70,13 @@ export interface IUIConfig {
// if true show all content details by default
showAllDetails?: boolean;
// if true show favorites first on the performers list page and tag performers tab
showFavoritesFirstPerformers?: boolean;
// if true show favorites first on the studios list page and tag studios tab
showFavoritesFirstStudios?: boolean;
// if true show favorites first on the tags list page
showFavoritesFirstTags?: boolean;
// if true the chromecast option will enabled
enableChromecast?: boolean;

View file

@ -0,0 +1,40 @@
import { useCallback, useMemo } from "react";
import { ListFilterModel } from "src/models/list-filter/filter";
type ListFilterHook = (filter: ListFilterModel) => ListFilterModel;
export const useFavoritesFirstFilterHook = (
showFavoritesFirst: boolean,
filterHook?: ListFilterHook
) => {
const favoritesFirstHook = useCallback((f: ListFilterModel) => {
const sortBy = f.sortBy ?? "name";
if (sortBy.startsWith("favorites_first_")) {
return f;
}
if (sortBy === "random") {
if (f.randomSeed === -1) {
// Match ListFilterModel random seed generation for stable paging.
f.randomSeed = Math.floor(Math.random() * 10 ** 8);
}
f.sortBy = `favorites_first_random_${f.randomSeed.toString()}`;
return f;
}
f.sortBy = `favorites_first_${sortBy}`;
return f;
}, []);
return useMemo(() => {
if (!showFavoritesFirst) {
return filterHook;
}
if (!filterHook) {
return favoritesFirstHook;
}
return (f: ListFilterModel) => favoritesFirstHook(filterHook(f));
}, [showFavoritesFirst, filterHook, favoritesFirstHook]);
};

View file

@ -718,6 +718,10 @@
"heading": "Show studio as text"
}
},
"favorites_first": {
"description": "When enabled, favourites will appear at the top of the list while respecting the current sort order within each group.",
"heading": "Show Favourites First"
},
"editing": {
"disable_dropdown_create": {
"description": "Remove the ability to create new objects from the dropdown selectors.",

View file

@ -15,6 +15,10 @@
"custom_locales": {
"heading": "Custom localization",
"option_label": "Custom localization enabled"
},
"favorites_first": {
"description": "When enabled, favorites will appear at the top of the list while respecting the current sort order within each group.",
"heading": "Show Favorites First"
}
}
},