diff --git a/__tests__/sitemaps.ts b/__tests__/sitemaps.ts index 0132abe492..ccbb633e0c 100644 --- a/__tests__/sitemaps.ts +++ b/__tests__/sitemaps.ts @@ -823,6 +823,26 @@ describe('GET /sitemaps/users.xml', () => { email: 'noposts@test.com', bio: 'Has no posts', }, + { + ...userBase, + id: 'vordr-user', + name: 'Shadow Banned User', + image: 'https://daily.dev/vordr.jpg', + username: 'vordruser', + email: 'vordr@test.com', + bio: 'Looks legit but is shadow banned', + flags: { vordr: true }, + }, + { + ...userBase, + id: 'non-vordr-flagged-user', + name: 'Non Vordr Flagged User', + image: 'https://daily.dev/non-vordr.jpg', + username: 'nonvordrflagged', + email: 'nonvordrflagged@test.com', + bio: 'Has flags but vordr is explicitly false', + flags: { vordr: false, trustScore: 5 }, + }, ]); await con.getRepository(Post).insert([ @@ -893,6 +913,20 @@ describe('GET /sitemaps/users.xml', () => { authorId: 'hidden-post-user', visible: false, }, + { + ...publicPostBase, + id: 'vordr-user-post', + shortId: 'vup', + title: 'Vordr User Post', + authorId: 'vordr-user', + }, + { + ...publicPostBase, + id: 'non-vordr-flagged-user-post', + shortId: 'nvfup', + title: 'Non Vordr Flagged User Post', + authorId: 'non-vordr-flagged-user', + }, ]); const res = await request(app.server) @@ -918,6 +952,10 @@ describe('GET /sitemaps/users.xml', () => { expect(res.text).not.toContain('/deletedpost'); expect(res.text).not.toContain('/hiddenpost'); expect(res.text).not.toContain('/noposts'); + expect(res.text).not.toContain('/vordruser'); + expect(res.text).toContain( + 'http://localhost:5002/nonvordrflagged', + ); }); }); diff --git a/__tests__/users.ts b/__tests__/users.ts index 12cf722829..db46b07ea1 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -1952,6 +1952,41 @@ describe('query user', () => { }); }); +describe('query user noindex', () => { + const QUERY = `query User($id: ID!) { + user(id: $id) { + noindex + } + }`; + + it('should be false for a user with reputation > 10 and no vordr flag', async () => { + await con + .getRepository(User) + .update({ id: '1' }, { reputation: 50, flags: {} }); + const res = await client.query(QUERY, { variables: { id: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.user).toEqual({ noindex: false }); + }); + + it('should be true when reputation <= 10', async () => { + await con + .getRepository(User) + .update({ id: '1' }, { reputation: 10, flags: {} }); + const res = await client.query(QUERY, { variables: { id: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.user).toEqual({ noindex: true }); + }); + + it('should be true when shadow banned (flags.vordr) even with high reputation', async () => { + await con + .getRepository(User) + .update({ id: '1' }, { reputation: 100, flags: { vordr: true } }); + const res = await client.query(QUERY, { variables: { id: '1' } }); + expect(res.errors).toBeFalsy(); + expect(res.data.user).toEqual({ noindex: true }); + }); +}); + describe('query user socialLinks', () => { const QUERY = `query User($id: ID!) { user(id: $id) { diff --git a/src/common/users.ts b/src/common/users.ts index 2daa568ed0..f9af31ffd5 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -23,6 +23,14 @@ import type { GQLKeyword } from '../schema/keywords'; import type { GQLUser } from '../schema/users'; import { UserExperienceType } from '../entity/user/experiences/types'; +/** + * Reputation at or below this value is treated as "thin" and triggers + * `noindex` on the user's public surfaces (profile page, post pages, sitemap + * exclusion). Users above this threshold are indexable unless gated by + * another signal (e.g. shadow ban via `flags.vordr`). + */ +export const MIN_INDEXABLE_REPUTATION = 10; + export interface User { id: string; email: string; diff --git a/src/graphorm/index.ts b/src/graphorm/index.ts index ffd6120a02..beb841dff9 100644 --- a/src/graphorm/index.ts +++ b/src/graphorm/index.ts @@ -52,6 +52,7 @@ import { UserVote, } from '../types'; import { whereVordrFilter } from '../common/vordr'; +import { MIN_INDEXABLE_REPUTATION } from '../common/users'; import { UserCompany, Post } from '../entity'; import { ContentPreferenceStatus, @@ -322,6 +323,10 @@ const obj = new GraphORM({ alias: { field: 'flags', type: 'jsonb' }, transform: (flags: UserFlags) => !!flags?.hackathonParticipant, }, + noindex: { + select: (_: Context, alias: string): string => + `(COALESCE((${alias}.flags->>'vordr')::boolean, false) OR ${alias}.reputation <= ${MIN_INDEXABLE_REPUTATION})`, + }, plusMemberSince: { alias: { field: 'subscriptionFlags', type: 'jsonb' }, transform: (subscriptionFlags: UserSubscriptionFlags) => diff --git a/src/routes/sitemaps.ts b/src/routes/sitemaps.ts index 05cfbb5db7..a126261bb7 100644 --- a/src/routes/sitemaps.ts +++ b/src/routes/sitemaps.ts @@ -12,7 +12,8 @@ import { import { ChannelHighlightDefinition } from '../entity/ChannelHighlightDefinition'; import { PostHighlight } from '../entity/PostHighlight'; import { ArchivePeriodType, ArchiveScopeType } from '../common/archive'; -import { getUserProfileUrl } from '../common/users'; +import { getUserProfileUrl, MIN_INDEXABLE_REPUTATION } from '../common/users'; +import { whereVordrFilter } from '../common/vordr'; import createOrGetConnection from '../db'; import { Readable } from 'stream'; import { ONE_HOUR_IN_SECONDS } from '../common/constants'; @@ -414,10 +415,11 @@ const buildUsersSitemapQuery = ( .select('u.username', 'username') .addSelect('u."updatedAt"', 'lastmod') .from(User, 'u') - .where('u.reputation > :minRep', { minRep: 10 }) + .where('u.reputation > :minRep', { minRep: MIN_INDEXABLE_REPUTATION }) .andWhere('u.bio IS NOT NULL') .andWhere(`btrim(u.bio) != ''`) .andWhere('u.username IS NOT NULL') + .andWhere(whereVordrFilter('u')) .andWhere((qb) => { const subQuery = qb .subQuery() diff --git a/src/schema/users.ts b/src/schema/users.ts index 6e74cfb319..cd176398ab 100644 --- a/src/schema/users.ts +++ b/src/schema/users.ts @@ -646,6 +646,10 @@ export const typeDefs = /* GraphQL */ ` Flexible social media links array (replaces individual social fields) """ socialLinks: [UserSocialLink!]! + """ + Whether search engines should not index this user's profile + """ + noindex: Boolean! } """