fix(api): respect age gating in search and stabilise discovery (#2598)

This commit is contained in:
Hampus
2026-09-08 19:07:59 +02:00
committed by GitHub
parent 20a15ac11d
commit 55846b24ea
10 changed files with 250 additions and 49 deletions
@@ -401,7 +401,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
const language =
params.primaryLanguage && isValidDiscoveryLanguage(params.primaryLanguage) ? params.primaryLanguage : undefined;
const tag = params.tag && params.tag.trim().length > 0 ? normalizeDiscoveryTag(params.tag) : undefined;
const sortBy = params.sortBy === 'member_count' ? 'memberCount' : 'relevance';
const sortBy = params.sortBy === 'relevance' ? 'relevance' : 'memberCount';
const filters: GuildSearchFilters = {
isDiscoverable: true,
discoveryCategory: params.categoryId,
@@ -444,7 +444,6 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
for (const guild of guilds) {
const counts = freshCounts.get(BigInt(guild.id) as GuildID);
if (counts) {
guild.member_count = counts.memberCount;
guild.online_count = counts.onlineCount;
}
}
@@ -68,23 +68,10 @@ export class GuildSearchService {
const includeNsfwRequested = searchParams.include_nsfw ?? false;
const canUserAccessNsfw =
guildIsAgeRestricted || includeNsfwRequested ? await this.getCanUserAccessNsfw(userId) : false;
if (guildIsAgeRestricted) {
if (!canUserAccessNsfw) {
throw new NsfwContentRequiresAgeVerificationError();
}
if (!includeNsfwRequested) {
const hitsPerPage = searchParams.hits_per_page ?? 25;
const page = searchParams.page ?? 1;
return {
channels: [],
messages: [],
total: 0,
hits_per_page: hitsPerPage,
page,
};
}
if (guildIsAgeRestricted && !canUserAccessNsfw) {
throw new NsfwContentRequiresAgeVerificationError();
}
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
const canIncludeNsfw = canUserAccessNsfw && (includeNsfwRequested || guildIsAgeRestricted);
const guildNsfw = guildData?.nsfw ?? false;
const channels = await this.channelRepository.listChannels(channelIds);
const channelMap = new Map<string, Channel>();
@@ -8,13 +8,21 @@ import type {
DiscoveryCategoryResponse,
DiscoveryGuildListResponse,
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
import type {GuildID} from '../../BrandedTypes';
import {createTestBotAccount} from '../../bot/tests/BotTestUtils';
import {setInjectedGatewayService} from '../../middleware/ServiceRegistry';
import {getGuildRepository} from '../../middleware/ServiceSingletons';
import {banUser} from '../../moderation/tests/ModerationTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {NoopLogger} from '../../test/mocks/NoopLogger';
import {NoopGatewayService} from '../../test/NoopGatewayService';
import {HTTP_STATUS, TEST_IDS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import syncDiscoveryIndex from '../../worker/tasks/SyncDiscoveryIndex';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '../../worker/WorkerContext';
import {createGuild, getUserGuilds} from './GuildTestUtils';
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
@@ -24,6 +32,30 @@ async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, mem
.execute();
}
interface LiveGuildCounts {
memberCount: number;
onlineCount: number;
}
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
class LiveCountsGatewayService extends NoopGatewayService {
constructor(private readonly liveCounts: Map<string, LiveGuildCounts>) {
super();
}
override async getDiscoveryGuildCounts(guildIds: Array<GuildID>): Promise<Map<GuildID, LiveGuildCounts>> {
const counts = new Map<GuildID, LiveGuildCounts>();
for (const guildId of guildIds) {
const live = this.liveCounts.get(guildId.toString());
if (live) {
counts.set(guildId, live);
}
}
return counts;
}
}
async function applyAndApprove(
harness: ApiTestHarness,
ownerToken: string,
@@ -44,12 +76,39 @@ async function applyAndApprove(
.execute();
}
async function createApprovedDiscoveryGuild(
harness: ApiTestHarness,
adminToken: string,
name: string,
memberCount: number,
): Promise<string> {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, name);
await setGuildMemberCount(harness, guild.id, memberCount);
await applyAndApprove(
harness,
owner.token,
adminToken,
guild.id,
`${name} welcomes everyone`,
DiscoveryCategories.GAMING,
);
return guild.id;
}
function expectNonIncreasing(counts: Array<number>): void {
for (let index = 1; index < counts.length; index++) {
expect(counts[index]).toBeLessThanOrEqual(counts[index - 1]);
}
}
describe('Discovery Search and Join', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness({search: 'enabled'});
});
afterEach(async () => {
clearWorkerDependencies();
await harness?.shutdown();
});
describe('categories', () => {
@@ -257,6 +316,82 @@ describe('Discovery Search and Join', () => {
.execute();
expect(results.guilds.length).toBeLessThanOrEqual(2);
});
test('should order results by the member count it reports back', async () => {
const liveCounts = new Map<string, LiveGuildCounts>();
setInjectedGatewayService(new LiveCountsGatewayService(liveCounts));
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
const guildIds: Array<string> = [];
for (const memberCount of [50, 40, 30, 20, 10]) {
guildIds.push(
await createApprovedDiscoveryGuild(harness, admin.token, `Ordered Guild ${memberCount}`, memberCount),
);
}
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 3});
liveCounts.set(guildIds[4], {memberCount: 500, onlineCount: 7});
const searcher = await createTestAccount(harness);
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
.get('/discovery/guilds?sort_by=member_count&limit=48')
.expect(HTTP_STATUS.OK)
.execute();
expect(results.guilds.map((guild) => guild.id)).toEqual(guildIds);
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
expect(results.guilds[0].online_count).toBe(3);
expect(results.guilds[4].online_count).toBe(7);
});
test('should rank by member count when the client omits sort_by', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
const guildsByCount = new Map<number, string>();
for (const memberCount of [30, 10, 20]) {
guildsByCount.set(
memberCount,
await createApprovedDiscoveryGuild(harness, admin.token, `Unsorted Guild ${memberCount}`, memberCount),
);
}
const searcher = await createTestAccount(harness);
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
.get('/discovery/guilds?limit=48')
.expect(HTTP_STATUS.OK)
.execute();
expect(results.guilds.map((guild) => guild.id)).toEqual([
guildsByCount.get(30),
guildsByCount.get(20),
guildsByCount.get(10),
]);
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
});
test('should not repeat guilds across pages when the discovery index is resynced', async () => {
const liveCounts = new Map<string, LiveGuildCounts>();
const gatewayService = new LiveCountsGatewayService(liveCounts);
setInjectedGatewayService(gatewayService);
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
const guildIds: Array<string> = [];
for (const [index, memberCount] of [60, 50, 40, 40, 30, 30].entries()) {
guildIds.push(await createApprovedDiscoveryGuild(harness, admin.token, `Paged Guild ${index}`, memberCount));
}
const searcher = await createTestAccount(harness);
const firstPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=0')
.expect(HTTP_STATUS.OK)
.execute();
expect(firstPage.guilds.map((guild) => guild.id)).toEqual([guildIds[0], guildIds[1]]);
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 0});
setWorkerDependenciesForTest({guildRepository: getGuildRepository(), gatewayService});
await syncDiscoveryIndex({}, WORKER_HELPERS);
const secondPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=2')
.expect(HTTP_STATUS.OK)
.execute();
const thirdPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=4')
.expect(HTTP_STATUS.OK)
.execute();
const paged = [...firstPage.guilds, ...secondPage.guilds, ...thirdPage.guilds].map((guild) => guild.id);
expect(new Set(paged).size).toBe(paged.length);
expect([...paged].sort()).toEqual([...guildIds].sort());
});
test('should require login to search', async () => {
await createBuilderWithoutAuth(harness).get('/discovery/guilds').expect(HTTP_STATUS.UNAUTHORIZED).execute();
});
@@ -344,7 +344,7 @@ describe('Message Search Permissions', () => {
}
expect(result.messages.length).toBe(0);
});
test('age-restricted guild requires include_nsfw: true', async () => {
test('age-restricted guild is searchable by an adult member without include_nsfw', async () => {
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
const guild = await createGuild(harness, owner.token, 'Age Restricted Search Guild');
const systemChannelId = guild.system_channel_id!;
@@ -363,7 +363,7 @@ describe('Message Search Permissions', () => {
if (!isSearchResult(excluded)) {
expect.fail('Expected search result but got indexing response');
}
expect(excluded.messages.length).toBe(0);
expect(excluded.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
const included = await createBuilder<MessageSearchResponse>(harness, owner.token)
.post('/search/messages')
.body({
@@ -379,6 +379,30 @@ describe('Message Search Permissions', () => {
expect(included.messages.length).toBeGreaterThan(0);
expect(included.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
});
test('age-restricted guild is searchable in a channel pinned to nsfw_override: false', async () => {
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
const guild = await createGuild(harness, owner.token, 'Age Restricted Override Guild');
const channel = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
.post(`/guilds/${guild.id}/channels`)
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw: false})
.execute();
expect(channel.nsfw_override).toBe(false);
await sendChannelMessage(harness, owner.token, channel.id, 'age restricted override searchable message');
await updateGuild(harness, owner.token, guild.id, {nsfw_level: GuildNSFWLevel.AGE_RESTRICTED});
await markGuildChannelsAsIndexed(harness, owner.token, guild.id);
const result = await createBuilder<MessageSearchResponse>(harness, owner.token)
.post('/search/messages')
.body({
content: 'age restricted override searchable',
context_guild_id: guild.id,
})
.expect(HTTP_STATUS.OK)
.execute();
if (!isSearchResult(result)) {
expect.fail('Expected search result but got indexing response');
}
expect(result.messages.some((m) => m.channel_id === channel.id)).toBe(true);
});
test('underage user cannot search messages in an age-restricted guild', async () => {
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
const underageMember = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
@@ -441,16 +441,25 @@ function collectGuildText(doc: SearchableGuild): Array<string | null> {
return [doc.name, doc.vanityUrlCode, doc.discoveryDescription, ...doc.discoveryTags];
}
const sortGuildsByCreatedAt = sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc');
const sortGuildsByMemberCount = sortNumericField<SearchableGuild, GuildSearchFilters>('memberCount', 'desc');
function sortGuilds(left: SearchableGuild, right: SearchableGuild, filters: GuildSearchFilters, query: string): number {
const sorter = filters.sortBy === 'memberCount' ? sortGuildsByMemberCount : sortGuildsByCreatedAt;
const delta = sorter(left, right, filters, query);
if (delta !== 0) return delta;
const leftId = BigInt(left.id);
const rightId = BigInt(right.id);
if (leftId === rightId) return 0;
return leftId > rightId ? -1 : 1;
}
class InMemoryGuildSearchService
extends InMemorySearchServiceBase<GuildSearchFilters, SearchableGuild>
implements IGuildSearchService
{
constructor() {
super(
matchesGuildFilters,
collectGuildText,
sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc'),
);
super(matchesGuildFilters, collectGuildText, sortGuilds);
}
async indexGuild(guild: Guild, discovery?: GuildDiscoveryContext): Promise<void> {
@@ -2,7 +2,6 @@
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import type {GuildID} from '../../BrandedTypes';
import {GuildDiscoveryRepository} from '../../guild/repositories/GuildDiscoveryRepository';
import {getGuildSearchService} from '../../SearchFactory';
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
@@ -17,7 +16,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
helpers.logger.warn('Search service not available, skipping discovery index sync');
return;
}
const {guildRepository, gatewayService} = getWorkerDependencies();
const {guildRepository} = getWorkerDependencies();
const discoveryRepository = new GuildDiscoveryRepository();
const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED);
if (approvedRows.length === 0) {
@@ -25,21 +24,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
return;
}
const guildIds = approvedRows.map((row) => row.guild_id);
let freshCounts = new Map<
GuildID,
{
memberCount: number;
onlineCount: number;
}
>();
try {
freshCounts = await gatewayService.getDiscoveryGuildCounts(guildIds);
} catch (error) {
helpers.logger.warn(
{error: error instanceof Error ? error.message : String(error)},
'Failed to fetch fresh guild counts from gateway, using database values',
);
}
let synced = 0;
for (let i = 0; i < guildIds.length; i += BATCH_SIZE) {
const batch = guildIds.slice(i, i + BATCH_SIZE);
@@ -54,7 +38,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
if (!guild) return null;
const discoveryRow = discoveryRows[index];
if (!discoveryRow || discoveryRow.status !== DiscoveryApplicationStatus.APPROVED) return null;
return {guild, discoveryRow, counts: freshCounts.get(guildId)};
return {guild, discoveryRow};
})
.filter((update): update is NonNullable<typeof update> => update != null);
await mapWithConcurrency(updates, UPDATE_CONCURRENCY, (update) =>
@@ -63,7 +47,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
categoryId: update.discoveryRow.category_type,
primaryLanguage: update.discoveryRow.primary_language ?? null,
tags: update.discoveryRow.custom_tags ?? [],
memberCount: update.counts?.memberCount,
}),
);
synced += updates.length;
@@ -99,9 +99,10 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
return Math.min(SKELETON_DISCOVERY_MAX_COLUMNS, Math.max(1, columnsThatFit));
}, [containerWidth, zoomLevel]);
const guilds = Discovery.guilds;
const loadedCount = Discovery.loadedCount;
const searchActive = Discovery.query.length > 0;
const rowCount = columns > 0 ? Math.ceil(guilds.length / columns) : 0;
const hasMore = guilds.length < Discovery.total;
const hasMore = loadedCount < Discovery.total;
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollerRef.current?.getViewportElement() ?? null,
@@ -114,10 +115,10 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
return;
}
void Discovery.search({
offset: guilds.length,
offset: loadedCount,
limit: PAGE_SIZE,
});
}, [guilds.length, hasMore]);
}, [loadedCount, hasMore]);
useEffect(() => {
const items = virtualizer.getVirtualItems();
const lastItem = items[items.length - 1];
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DiscoveryGuild, DiscoverySearchResponse} from '@app/features/discovery/commands/DiscoveryCommands';
import * as DiscoveryCommands from '@app/features/discovery/commands/DiscoveryCommands';
import {beforeEach, describe, expect, test, vi} from 'vitest';
import Discovery from './Discovery';
vi.mock('@app/features/discovery/commands/DiscoveryCommands', () => ({
searchGuilds: vi.fn(),
getCategories: vi.fn(),
}));
function guild(id: string, memberCount: number): DiscoveryGuild {
return {
id,
name: `Guild ${id}`,
icon: null,
banner: null,
description: null,
category_type: 0,
primary_language: null,
custom_tags: [],
member_count: memberCount,
online_count: 0,
features: [],
verification_level: 0,
};
}
function page(guilds: Array<DiscoveryGuild>, total: number): DiscoverySearchResponse {
return {guilds, total, categoryCounts: null};
}
describe('Discovery.search', () => {
beforeEach(() => {
vi.mocked(DiscoveryCommands.searchGuilds).mockReset();
Discovery.reset();
});
test('does not append a guild that a previous page already returned', async () => {
vi.mocked(DiscoveryCommands.searchGuilds)
.mockResolvedValueOnce(page([guild('1', 50), guild('2', 40)], 4))
.mockResolvedValueOnce(page([guild('2', 40), guild('3', 30)], 4));
await Discovery.search({limit: 2, offset: 0});
await Discovery.search({limit: 2, offset: Discovery.loadedCount});
expect(Discovery.guilds.map((entry) => entry.id)).toEqual(['1', '2', '3']);
});
test('advances the pagination offset by what the server returned, not by what survived deduping', async () => {
vi.mocked(DiscoveryCommands.searchGuilds)
.mockResolvedValueOnce(page([guild('1', 50), guild('2', 40)], 4))
.mockResolvedValueOnce(page([guild('2', 40), guild('3', 30)], 4));
await Discovery.search({limit: 2, offset: 0});
await Discovery.search({limit: 2, offset: Discovery.loadedCount});
expect(Discovery.loadedCount).toBe(4);
});
});
@@ -8,6 +8,7 @@ const DEFAULT_DISCOVERY_PAGE_SIZE = 36;
class Discovery {
guilds: Array<DiscoveryGuild> = [];
loadedCount = 0;
total = 0;
loading = false;
error = false;
@@ -53,6 +54,7 @@ class Discovery {
this.error = false;
if (offset === 0 && searchModeChanged) {
this.guilds = [];
this.loadedCount = 0;
this.total = 0;
}
this.query = query;
@@ -78,8 +80,10 @@ class Discovery {
if (offset === 0) {
this.guilds = result.guilds;
} else {
this.guilds = [...this.guilds, ...result.guilds];
const seen = new Set(this.guilds.map((guild) => guild.id));
this.guilds = [...this.guilds, ...result.guilds.filter((guild) => !seen.has(guild.id))];
}
this.loadedCount = offset + result.guilds.length;
this.total = result.total;
this.categoryCounts = result.categoryCounts;
this.loading = false;
@@ -136,6 +140,7 @@ class Discovery {
reset(): void {
this.activeSearchToken += 1;
this.guilds = [];
this.loadedCount = 0;
this.total = 0;
this.categoryCounts = null;
this.loading = false;
@@ -264,7 +264,7 @@ The `current` scope requires one of `context_guild_id` and `context_channel_id`.
Fluxer resolves a supplied `context_guild_id` before anything else. A guild that cannot be resolved returns 404 `UNKNOWN_GUILD`, and a guild the caller is not a member of returns 403 `MISSING_PERMISSIONS`. A guild context searches that guild, restricted to `channel_ids` when supplied and to every channel of the guild otherwise, and every channel in the resulting set must belong to that guild. A channel the caller cannot both view and read history in is dropped when the set came from the guild, and rejected with 403 `MISSING_PERMISSIONS` when the caller named it in `channel_ids`. An age-gated channel that `include_nsfw` does not admit is removed from the set before the permission check runs, so it never causes that refusal.
A guild whose NSFW level is age restricted refuses a caller who is not old enough with 403 `NSFW_CONTENT_AGE_RESTRICTED`. An old-enough caller who did not set `include_nsfw` receives an empty result from such a guild. The request still succeeds.
A guild whose NSFW level is age restricted refuses a caller who is not old enough with 403 `NSFW_CONTENT_AGE_RESTRICTED`. An old-enough caller searches every channel of such a guild, including its age-gated channels, whether or not `include_nsfw` is set.
A channel context searches only that channel and ignores `channel_ids`, `cursor`, and `include_nsfw`. When the caller cannot read the channel's history and the guild defines no message history cutoff, the search returns an empty result.