Skip to content
Merged
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
38 changes: 38 additions & 0 deletions __tests__/sitemaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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)
Expand All @@ -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(
'<loc>http://localhost:5002/nonvordrflagged</loc>',
);
});
});

Expand Down
35 changes: 35 additions & 0 deletions __tests__/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/common/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/graphorm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) =>
Expand Down
6 changes: 4 additions & 2 deletions src/routes/sitemaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions src/schema/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
}

"""
Expand Down
Loading