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;