Skip to content
Open
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
18 changes: 18 additions & 0 deletions __tests__/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1241,6 +1241,15 @@ describe('query searchBookmarksSuggestions', () => {
expect(res.data.searchBookmarksSuggestions.hits.length).toBeGreaterThan(0);
});

it('should return ValidationError for invalid tsquery syntax', async () => {
loggedUser = '1';
await saveFixtures(con, Bookmark, bookmarksFixture);
const res = await client.query(QUERY('& | !'));
expect(res.errors?.length).toBe(1);
expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT');
expect(res.errors[0].message).toBe('Invalid search query');
});

it('should handle special characters in search (C#)', async () => {
loggedUser = '1';
await con.getRepository(ArticlePost).save({
Expand Down Expand Up @@ -1302,6 +1311,15 @@ describe('query searchBookmarks', () => {
const res = await client.query(QUERY('not found'));
expect(res.data).toMatchSnapshot();
});

it('should return ValidationError for invalid tsquery syntax', async () => {
loggedUser = '1';
await saveFixtures(con, Bookmark, bookmarksFixture);
const res = await client.query(QUERY('((!))'));
expect(res.errors?.length).toBe(1);
expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT');
expect(res.errors[0].message).toBe('Invalid search query');
});
});

describe('mutation setBookmarkReminder', () => {
Expand Down
44 changes: 44 additions & 0 deletions __tests__/common/processSearchQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ValidationError } from 'apollo-server-errors';
import { processSearchQuery } from '../../src/schema/common';

describe('processSearchQuery', () => {
it('should join words with AND operator and add prefix', () => {
expect(processSearchQuery('react vue')).toBe('react & vue:*');
});

it('should handle single word', () => {
expect(processSearchQuery('react')).toBe('react:*');
});

it('should trim whitespace', () => {
expect(processSearchQuery(' react ')).toBe('react:*');
});

it('should preserve special characters for programming languages', () => {
expect(processSearchQuery('c++')).toBe("'c++':*");
expect(processSearchQuery('c#')).toBe("'c#':*");
expect(processSearchQuery('node.js')).toBe("'node.js':*");
expect(processSearchQuery('.net')).toBe("'.net':*");
});

it('should strip tsquery metacharacters from regular queries', () => {
expect(processSearchQuery('react & vue')).toBe('react & vue:*');
expect(processSearchQuery('react | vue')).toBe('react & vue:*');
expect(processSearchQuery('test (query)')).toBe('test & query:*');
});

it('should strip tsquery metacharacters from special char queries', () => {
expect(processSearchQuery('c++ & tricks')).toBe("'c++ tricks':*");
});

it('should throw ValidationError for empty query', () => {
expect(() => processSearchQuery('')).toThrow(ValidationError);
expect(() => processSearchQuery(' ')).toThrow(ValidationError);
});

it('should throw ValidationError when query becomes empty after stripping', () => {
expect(() => processSearchQuery('& | !')).toThrow(ValidationError);
expect(() => processSearchQuery('(())')).toThrow(ValidationError);
expect(() => processSearchQuery(':*')).toThrow(ValidationError);
});
});
18 changes: 18 additions & 0 deletions __tests__/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3621,6 +3621,14 @@ describe('query searchReadingHistorySuggestions', () => {
res.data.searchReadingHistorySuggestions.hits.length,
).toBeGreaterThan(0);
});

it('should return ValidationError for invalid tsquery syntax', async () => {
loggedUser = '1';
const res = await client.query(QUERY('& | !'));
expect(res.errors?.length).toBe(1);
expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT');
expect(res.errors[0].message).toBe('Invalid search query');
});
});

describe('query search reading history', () => {
Expand Down Expand Up @@ -3678,6 +3686,16 @@ describe('query search reading history', () => {
expect(res.errors).toBeFalsy();
expect(res.data).toMatchSnapshot();
});

it('should return ValidationError for invalid tsquery syntax', async () => {
loggedUser = '1';
const res = await client.query(QUERY, {
variables: { query: '& | !' },
});
expect(res.errors?.length).toBe(1);
expect(res.errors[0].extensions?.code).toBe('BAD_USER_INPUT');
expect(res.errors[0].message).toBe('Invalid search query');
});
});

describe('mutation updateUserProfile', () => {
Expand Down
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export enum TypeOrmError {
NULL_VIOLATION = '23502',
FOREIGN_KEY = '23503',
DUPLICATE_ENTRY = '23505',
SYNTAX_ERROR = '42601',
USER_CONSTRAINT = 'FK_dce2a8927967051c447ae10bc8b',
DEADLOCK_DETECTED = '40P01',
}
Expand Down
35 changes: 22 additions & 13 deletions src/schema/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Connection, ConnectionArguments } from 'graphql-relay';
import {
getSearchQuery,
GQLEmptyResponse,
handleSearchQueryError,
offsetPageGenerator,
Page,
PageGenerator,
Expand Down Expand Up @@ -625,8 +626,9 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
{ query }: { query: string },
ctx: AuthContext,
) => {
const hits: { title: string }[] = await ctx.con.query(
`
try {
const hits: { title: string }[] = await ctx.con.query(
`
WITH search AS (${getSearchQuery('$2')})
select ts_headline(title, search.query,
'StartSel = <strong>, StopSel = </strong>') as title
Expand All @@ -638,24 +640,31 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
order by views desc
limit 5;
`,
[ctx.userId, processSearchQuery(query)],
);
return {
query,
hits,
};
[ctx.userId, processSearchQuery(query)],
);
return {
query,
hits,
};
} catch (error) {
return handleSearchQueryError(error);
}
},
searchBookmarks: async (
source,
args: FeedArgs & BookmarksArgs & { query: string },
ctx: AuthContext,
info,
): Promise<Connection<GQLPost> & { query: string }> => {
const res = await searchResolver(source, args, ctx, info);
return {
...res,
query: args.query,
};
try {
const res = await searchResolver(source, args, ctx, info);
return {
...res,
query: args.query,
};
} catch (error) {
return handleSearchQueryError(error);
}
},
},
};
29 changes: 21 additions & 8 deletions src/schema/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import z from 'zod';
import { ValidationError } from 'apollo-server-errors';
import { IFieldResolver, IResolvers } from '@graphql-tools/utils';
import { TypeOrmError, TypeORMQueryFailedError } from '../errors';
import {
Connection,
ConnectionArguments,
Expand Down Expand Up @@ -195,22 +197,33 @@ export interface PageGenerator<
export const getSearchQuery = (param: string): string =>
`SELECT to_tsquery('english', ${param}) AS query`;

export const handleSearchQueryError = (error: unknown): never => {
const err = error as TypeORMQueryFailedError;
if (err?.code === TypeOrmError.SYNTAX_ERROR) {
throw new ValidationError('Invalid search query');
}
throw error;
};

export const processSearchQuery = (query: string): string => {
const trimmed = query.trim();
const stripped = trimmed.replace(/[&|!()<>:*']/g, ' ').trim();

// Check if query contains special characters that should be preserved
// Common programming language patterns: c#, c++, f#, .net, node.js, etc.
const hasSpecialChars = /[#+.\-]/.test(trimmed);
if (!stripped) {
throw new ValidationError('Invalid search query');
}

// Programming language patterns (c#, c++, .net, node.js) use phrase search
const hasSpecialChars = /[#+.\-]/.test(trimmed);
if (hasSpecialChars) {
// For queries with special characters, use phrase search to preserve them
// Replace single quotes with double single quotes to escape them
const escaped = trimmed.replace(/'/g, "''");
const escaped = trimmed
.replace(/[&|!()<>:*]/g, ' ')
.trim()
.replace(/'/g, "''");
return `'${escaped}':*`;
}

// For regular queries, use the original AND logic with prefix matching
return trimmed.split(' ').join(' & ') + ':*';
return stripped.split(/\s+/).join(' & ') + ':*';
};

export const mimirOffsetGenerator = <
Expand Down
35 changes: 25 additions & 10 deletions src/schema/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,12 @@ import {
voteHotTake,
systemUserIds,
} from '../common';
import { getSearchQuery, GQLEmptyResponse, processSearchQuery } from './common';
import {
getSearchQuery,
GQLEmptyResponse,
handleSearchQueryError,
processSearchQuery,
} from './common';
import { ActiveView } from '../entity/ActiveView';
import graphorm from '../graphorm';
import { GraphQLResolveInfo } from 'graphql';
Expand Down Expand Up @@ -2370,8 +2375,9 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
{ query }: { query: string },
ctx: Context,
) => {
const hits: { title: string }[] = await ctx.con.query(
`
try {
const hits: { title: string }[] = await ctx.con.query(
`
WITH search AS (${getSearchQuery('$2')})
select distinct(ts_headline(title, search.query,
('StartSel = <strong>, StopSel = </strong>'))) as title
Expand All @@ -2385,19 +2391,28 @@ export const resolvers: IResolvers<unknown, BaseContext> = {
order by title desc
limit 5;
`,
[ctx.userId, processSearchQuery(query)],
);
return {
query,
hits,
};
[ctx.userId, processSearchQuery(query)],
);
return {
query,
hits,
};
} catch (error) {
return handleSearchQueryError(error);
}
},
searchReadingHistory: async (
source,
args: ConnectionArguments & { query: string },
ctx: AuthContext,
info,
): Promise<Connection<GQLView>> => readHistoryResolver(args, ctx, info),
): Promise<Connection<GQLView>> => {
try {
return await readHistoryResolver(args, ctx, info);
} catch (error) {
return handleSearchQueryError(error);
}
},
readHistory: async (
_,
args: ConnectionArguments & { isPublic?: boolean },
Expand Down
Loading