diff --git a/backend/src/index.ts b/backend/src/index.ts index 2aa7b07e..3b4cab2d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,6 +9,7 @@ import { resolvers } from "~/schema/resolvers.generated"; import { fromNodeHeaders, toNodeHandler } from "better-auth/node"; import { auth } from "~/auth"; import { createBookGenresLoader } from "~/schema/loaders"; +import { maxAliasesRule } from "~/schema/requestLimits"; export interface ApolloContext { session: Awaited>; @@ -31,6 +32,7 @@ const server = new ApolloServer({ typeDefs, resolvers, plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], + validationRules: [maxAliasesRule], }); await server.start(); diff --git a/backend/src/schema/books/resolvers/Query/searchBooks.ts b/backend/src/schema/books/resolvers/Query/searchBooks.ts index 288be837..60a680dd 100644 --- a/backend/src/schema/books/resolvers/Query/searchBooks.ts +++ b/backend/src/schema/books/resolvers/Query/searchBooks.ts @@ -13,19 +13,34 @@ import { } from "drizzle-orm"; import { GraphQLError } from "graphql/error"; import { db } from "~/db"; +import { assertLimit, assertOffset } from "~/schema/pagination"; import { bookGenres, books, bookToGenres } from "~/db/schema"; import type { QueryResolvers } from "~/schema/types.generated"; +const MAX_GENRES = 15; +const MAX_TEXT_LENGTH = 200; + export const searchBooks: NonNullable = async ( _parent, args, ) => { - if (args.limit < 0 || args.offset < 0) { - throw new GraphQLError("Limit and offset cannot be negative.", { + assertLimit(args.limit); + assertOffset(args.offset); + + if (args.genres && args.genres.length > MAX_GENRES) { + throw new GraphQLError(`You may only select up to ${MAX_GENRES} genres.`, { extensions: { code: "BAD_USER_INPUT" }, }); } + const texts = [args.title, args.series, args.author, args.publisher]; + if (texts.some((text) => text && text.length > MAX_TEXT_LENGTH)) { + throw new GraphQLError( + `Search text may only be up to ${MAX_TEXT_LENGTH} characters.`, + { extensions: { code: "BAD_USER_INPUT" } }, + ); + } + const conditions: SQL[] = []; if (args.title && args.fuzzy) { diff --git a/backend/src/schema/books/schema.graphql b/backend/src/schema/books/schema.graphql index fc36ebe8..a240b27c 100644 --- a/backend/src/schema/books/schema.graphql +++ b/backend/src/schema/books/schema.graphql @@ -36,7 +36,7 @@ extend type Query { searchBooks( title: String "Match titles that are similar to the title, for example with typos, ordered by similarity" - fuzzy: Boolean = false + fuzzy: Boolean! = false series: String author: String language: String @@ -46,10 +46,10 @@ extend type Query { minPages: Int maxPages: Int minRatings: Int - limit: Int = 12 - offset: Int = 0 - sortBy: BookSortField = ID - sortDirection: SortDirection = ASC + limit: Int! = 12 + offset: Int! = 0 + sortBy: BookSortField! = ID + sortDirection: SortDirection! = ASC ): BookSearchResult! } diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts index 041355ed..7e5fb0a4 100644 --- a/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts +++ b/backend/src/schema/favorites/resolvers/Query/favoriteBooks.ts @@ -2,6 +2,7 @@ import { GraphQLError } from "graphql/error"; import { eq } from "drizzle-orm"; import type { QueryResolvers } from "~/schema/types.generated"; import { db } from "~/db"; +import { assertLimit, assertOffset } from "~/schema/pagination"; import { favoriteBooks as favoriteBooksTable } from "~/db/schema"; export const favoriteBooks: NonNullable< @@ -16,11 +17,8 @@ export const favoriteBooks: NonNullable< ); } - if (arg.limit < 0 || arg.offset < 0) { - throw new GraphQLError("Limit and offset cannot be negative.", { - extensions: { code: "BAD_USER_INPUT" }, - }); - } + assertLimit(arg.limit); + assertOffset(arg.offset); const [foundBooks, totalCount] = await Promise.all([ db.query.favoriteBooks.findMany({ diff --git a/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts index 2a7d1625..d03d0c0c 100644 --- a/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts +++ b/backend/src/schema/favorites/resolvers/Query/favoriteGenres.ts @@ -2,6 +2,7 @@ import { GraphQLError } from "graphql/error"; import { asc, count, desc, eq } from "drizzle-orm"; import type { QueryResolvers } from "~/schema/types.generated"; import { db } from "~/db"; +import { assertLimit } from "~/schema/pagination"; import { bookGenres, bookToGenres, favoriteBooks } from "~/db/schema"; export const favoriteGenres: NonNullable< @@ -16,6 +17,8 @@ export const favoriteGenres: NonNullable< ); } + assertLimit(arg.limit); + const genres = await db .select({ genre: bookGenres.genre }) .from(favoriteBooks) diff --git a/backend/src/schema/favorites/schema.graphql b/backend/src/schema/favorites/schema.graphql index 87732cd4..f5fad9d9 100644 --- a/backend/src/schema/favorites/schema.graphql +++ b/backend/src/schema/favorites/schema.graphql @@ -1,10 +1,10 @@ extend type Query { "Favorite books of the logged-in user, newest first." - favoriteBooks(limit: Int = 12, offset: Int = 0): BookSearchResult! + favoriteBooks(limit: Int! = 12, offset: Int! = 0): BookSearchResult! "ID of every book the logged-in user that has been added to their favorites." favoriteBookIds: [Int!]! "The logged-in user's most common genres among their favorite books." - favoriteGenres(limit: Int = 3): [String!]! + favoriteGenres(limit: Int! = 3): [String!]! } extend type Mutation { diff --git a/backend/src/schema/pagination.ts b/backend/src/schema/pagination.ts new file mode 100644 index 00000000..2c6476d1 --- /dev/null +++ b/backend/src/schema/pagination.ts @@ -0,0 +1,27 @@ +import { GraphQLError } from "graphql/error"; + +const MAX_LIMIT = 100; + +function assertLimit(limit: number) { + if (limit < 0) { + throw new GraphQLError("Limit cannot be negative.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } + + if (limit > MAX_LIMIT) { + throw new GraphQLError(`Limit must be at most ${MAX_LIMIT}.`, { + extensions: { code: "BAD_USER_INPUT" }, + }); + } +} + +function assertOffset(offset: number) { + if (offset < 0) { + throw new GraphQLError("Offset cannot be negative.", { + extensions: { code: "BAD_USER_INPUT" }, + }); + } +} + +export { assertLimit, assertOffset }; diff --git a/backend/src/schema/requestLimits.ts b/backend/src/schema/requestLimits.ts new file mode 100644 index 00000000..4aeeeaf5 --- /dev/null +++ b/backend/src/schema/requestLimits.ts @@ -0,0 +1,31 @@ +import { GraphQLError, type ASTVisitor, type ValidationContext } from "graphql"; + +const MAX_ALIASES = 10; + +// The same field can only be requested several times in one operation by +// giving each copy an alias, e.g. `a: searchBooks(limit: 100) b: searchBooks(...)`. +// We should therefore limit the number of aliases to prevent abuse, +// e.g. a single request fetching multiple thousand rows. +function maxAliasesRule(context: ValidationContext): ASTVisitor { + let aliases = 0; + + return { + Field(node) { + if (node.alias) aliases++; + }, + Document: { + leave() { + if (aliases > MAX_ALIASES) { + // Apollo Server responds with GRAPHQL_VALIDATION_FAILED and HTTP 400 + context.reportError( + new GraphQLError( + `Operation may use at most ${MAX_ALIASES} aliases, found ${aliases}.`, + ), + ); + } + }, + }, + }; +} + +export { maxAliasesRule }; diff --git a/backend/src/schema/schema.generated.graphqls b/backend/src/schema/schema.generated.graphqls index 30f11856..e51b58d5 100644 --- a/backend/src/schema/schema.generated.graphqls +++ b/backend/src/schema/schema.generated.graphqls @@ -42,10 +42,10 @@ type Query { favoriteBookIds: [Int!]! """Favorite books of the logged-in user, newest first.""" - favoriteBooks(limit: Int = 12, offset: Int = 0): BookSearchResult! + favoriteBooks(limit: Int! = 12, offset: Int! = 0): BookSearchResult! """The logged-in user's most common genres among their favorite books.""" - favoriteGenres(limit: Int = 3): [String!]! + favoriteGenres(limit: Int! = 3): [String!]! genres: [String!]! languages: [String!]! searchBooks( @@ -55,18 +55,18 @@ type Query { """ Match titles that are similar to the title, for example with typos, ordered by similarity """ - fuzzy: Boolean = false + fuzzy: Boolean! = false genres: [String!] language: String - limit: Int = 12 + limit: Int! = 12 maxPages: Int minPages: Int minRatings: Int - offset: Int = 0 + offset: Int! = 0 publisher: String series: String - sortBy: BookSortField = ID - sortDirection: SortDirection = ASC + sortBy: BookSortField! = ID + sortDirection: SortDirection! = ASC title: String ): BookSearchResult! } diff --git a/backend/src/schema/typeDefs.generated.ts b/backend/src/schema/typeDefs.generated.ts index 3f3eeb3b..8f3cb3e2 100644 --- a/backend/src/schema/typeDefs.generated.ts +++ b/backend/src/schema/typeDefs.generated.ts @@ -1,2 +1,2 @@ import type { DocumentNode } from 'graphql'; - export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file + export const typeDefs = {"kind":"Document","definitions":[{"name":{"kind":"Name","value":"Query"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"book"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"languages"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormats"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"searchBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","description":{"kind":"StringValue","value":"Match titles that are similar to the title, for example with typos, ordered by similarity","block":false},"name":{"kind":"Name","value":"fuzzy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}},"defaultValue":{"kind":"BooleanValue","value":false}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"maxPages"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"minRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortBy"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSortField"}}},"defaultValue":{"kind":"EnumValue","value":"ID"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"sortDirection"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SortDirection"}}},"defaultValue":{"kind":"EnumValue","value":"ASC"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"Favorite books of the logged-in user, newest first.","block":false},"name":{"kind":"Name","value":"favoriteBooks"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"12"}},{"kind":"InputValueDefinition","name":{"kind":"Name","value":"offset"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"0"}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"BookSearchResult"}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"ID of every book the logged-in user that has been added to their favorites.","block":false},"name":{"kind":"Name","value":"favoriteBookIds"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"FieldDefinition","description":{"kind":"StringValue","value":"The logged-in user's most common genres among their favorite books.","block":false},"name":{"kind":"Name","value":"favoriteGenres"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"limit"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},"defaultValue":{"kind":"IntValue","value":"3"}}],"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}}],"directives":[],"interfaces":[]},{"name":{"kind":"Name","value":"Mutation"},"kind":"ObjectTypeDefinition","fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"addFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"removeFavoriteBook"},"arguments":[{"kind":"InputValueDefinition","name":{"kind":"Name","value":"bookId"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}}],"directives":[],"interfaces":[]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"Book"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"id"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"isbn"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"title"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"series"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"author"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"description"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"language"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"bookFormat"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"edition"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"pages"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publisher"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"publishDate"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"coverImgUrl"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"genres"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"rating"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Float"}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"numRatings"},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"BookSortField"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ID"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"RATING"}}]},{"kind":"EnumTypeDefinition","name":{"kind":"Name","value":"SortDirection"},"values":[{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"ASC"}},{"kind":"EnumValueDefinition","name":{"kind":"Name","value":"DESC"}}]},{"kind":"ObjectTypeDefinition","name":{"kind":"Name","value":"BookSearchResult"},"fields":[{"kind":"FieldDefinition","name":{"kind":"Name","value":"books"},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Book"}}}}}},{"kind":"FieldDefinition","name":{"kind":"Name","value":"totalCount"},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}]},{"kind":"SchemaDefinition","operationTypes":[{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Query"}},"operation":"query"},{"kind":"OperationTypeDefinition","type":{"kind":"NamedType","name":{"kind":"Name","value":"Mutation"}},"operation":"mutation"}]}]} as unknown as DocumentNode \ No newline at end of file diff --git a/backend/src/schema/types.generated.ts b/backend/src/schema/types.generated.ts index 29d54c4d..53afedaa 100644 --- a/backend/src/schema/types.generated.ts +++ b/backend/src/schema/types.generated.ts @@ -83,31 +83,31 @@ export type QuerybookArgs = { export type QueryfavoriteBooksArgs = { - limit?: InputMaybe; - offset?: InputMaybe; + limit?: Scalars['Int']['input']; + offset?: Scalars['Int']['input']; }; export type QueryfavoriteGenresArgs = { - limit?: InputMaybe; + limit?: Scalars['Int']['input']; }; export type QuerysearchBooksArgs = { author?: InputMaybe; bookFormat?: InputMaybe; - fuzzy?: InputMaybe; + fuzzy?: Scalars['Boolean']['input']; genres?: InputMaybe>; language?: InputMaybe; - limit?: InputMaybe; + limit?: Scalars['Int']['input']; maxPages?: InputMaybe; minPages?: InputMaybe; minRatings?: InputMaybe; - offset?: InputMaybe; + offset?: Scalars['Int']['input']; publisher?: InputMaybe; series?: InputMaybe; - sortBy?: InputMaybe; - sortDirection?: InputMaybe; + sortBy?: BookSortField; + sortDirection?: SortDirection; title?: InputMaybe; }; diff --git a/frontend/app/api/types/graphql.generated.ts b/frontend/app/api/types/graphql.generated.ts index af2915fa..615c9434 100644 --- a/frontend/app/api/types/graphql.generated.ts +++ b/frontend/app/api/types/graphql.generated.ts @@ -23,8 +23,8 @@ export type BookFiltersQueryVariables = Exact<{ [key: string]: never; }>; export type BookFiltersQuery = { genres: Array, languages: Array, bookFormats: Array }; export type FavoriteBooksQueryVariables = Exact<{ - limit?: number | null | undefined; - offset?: number | null | undefined; + limit: number; + offset: number; }>; @@ -51,17 +51,17 @@ export type RemoveFavoriteBookMutation = { removeFavoriteBook: boolean }; export type SearchBooksQueryVariables = Exact<{ title?: string | null | undefined; - fuzzy?: boolean | null | undefined; + fuzzy?: boolean; genres?: Array | string | null | undefined; language?: string | null | undefined; bookFormat?: string | null | undefined; minPages?: number | null | undefined; maxPages?: number | null | undefined; minRatings?: number | null | undefined; - sortBy?: BookSortField | null | undefined; - sortDirection?: SortDirection | null | undefined; - limit?: number | null | undefined; - offset?: number | null | undefined; + sortBy?: BookSortField; + sortDirection?: SortDirection; + limit: number; + offset: number; }>; diff --git a/frontend/app/components/books/BookFilters/BookFilters.test.tsx b/frontend/app/components/books/BookFilters/BookFilters.test.tsx index afb84f0d..d66329d0 100644 --- a/frontend/app/components/books/BookFilters/BookFilters.test.tsx +++ b/frontend/app/components/books/BookFilters/BookFilters.test.tsx @@ -3,6 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createRoutesStub, Link, useSearchParams } from "react-router"; import { BookFilters } from "~/components/books/BookFilters/BookFilters"; +import { toast } from "~/components/ui/Toast"; vi.mock("~/hooks/useBookFilters", () => ({ useBookFilters: () => ({ @@ -65,6 +66,26 @@ describe("FilterBooksSection", () => { ); }); + it("shows an error and keeps the genres when selecting more than 15", async () => { + const addToast = vi.spyOn(toast, "add"); + const user = userEvent.setup(); + const fifteenGenres = Array.from({ length: 15 }, (_, i) => `Genre${i}`); + const query = fifteenGenres.map((genre) => `genres=${genre}`).join("&"); + render(); + + await user.type(screen.getByLabelText("Genres"), "Sci"); + await user.click( + await screen.findByRole("option", { name: "Science Fiction" }), + ); + + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error" }), + ); + expect(screen.getByTestId("genres")).toHaveTextContent( + fifteenGenres.join(","), + ); + }); + it("shows the selected language from the URL", () => { render(); diff --git a/frontend/app/components/books/BookFilters/BookFilters.tsx b/frontend/app/components/books/BookFilters/BookFilters.tsx index 790a7bbc..46a77b52 100644 --- a/frontend/app/components/books/BookFilters/BookFilters.tsx +++ b/frontend/app/components/books/BookFilters/BookFilters.tsx @@ -17,6 +17,8 @@ import { } from "~/components/ui/Combobox"; import { MinRatingsFilter } from "~/components/books/MinRatingsFilter/MinRatingsFilter"; import { PagesFilter } from "~/components/books/PagesFilter/PagesFilter"; +import { toast } from "~/components/ui/Toast"; +import { MAX_GENRES } from "~/lib/constants"; function BookFilters() { const [, setSearchParams] = useSearchParams(); @@ -65,6 +67,13 @@ function BookFilters() { items={genres} value={filters.genres ?? []} onValueChange={(selected) => { + if (selected.length > MAX_GENRES) { + toast.add({ + description: `You can select up to ${MAX_GENRES} genres.`, + type: "error", + }); + return; + } setSearchParams((searchParams) => { searchParams.delete("genres"); selected.forEach((genre) => diff --git a/frontend/app/components/books/SearchBar/SearchBar.test.tsx b/frontend/app/components/books/SearchBar/SearchBar.test.tsx index 75e0eeba..8b6e0e0b 100644 --- a/frontend/app/components/books/SearchBar/SearchBar.test.tsx +++ b/frontend/app/components/books/SearchBar/SearchBar.test.tsx @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SearchBar } from "~/components/books/SearchBar/SearchBar"; +import { toast } from "~/components/ui/Toast"; import { createRoutesStub, useSearchParams } from "react-router"; // Shows the current "q" param so tests can assert what SearchBar wrote to the URL. @@ -50,6 +51,25 @@ describe("SearchBar", () => { ); }); + it("cuts the query at 200 characters and shows an error", async () => { + const addToast = vi.spyOn(toast, "add"); + const user = userEvent.setup(); + render(); + + const searchbox = screen.getByRole("searchbox", { + name: "Search for books", + }); + await user.click(searchbox); + await user.paste("a".repeat(250)); + await user.type(searchbox, "b"); + + expect(searchbox).toHaveValue("a".repeat(200)); + expect(addToast).toHaveBeenCalledTimes(1); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ type: "error" }), + ); + }); + it("shows the number of active filters", () => { render(); diff --git a/frontend/app/components/books/SearchBar/SearchBar.tsx b/frontend/app/components/books/SearchBar/SearchBar.tsx index 6225530e..4bfa4fc3 100644 --- a/frontend/app/components/books/SearchBar/SearchBar.tsx +++ b/frontend/app/components/books/SearchBar/SearchBar.tsx @@ -18,6 +18,8 @@ import { import { useSearchParams } from "react-router"; import { useDebounce } from "~/hooks/useDebounce"; import { useBookSearchParams } from "~/hooks/useBookSearchParams"; +import { toast } from "~/components/ui/Toast"; +import { MAX_QUERY_LENGTH } from "~/lib/constants"; const SEARCH_DEBOUNCE_MS = 500; @@ -32,6 +34,7 @@ function SearchBar({ resultCount }: SearchBarProps) { const [inputField, setInputField] = useState(query); const debouncedQuery = useDebounce(inputField, SEARCH_DEBOUNCE_MS); const newQueryFromInputRef = useRef(false); + const tooLongToastOpenRef = useRef(false); useEffect(() => { if (debouncedQuery !== inputField || debouncedQuery === query) { @@ -80,8 +83,21 @@ function SearchBar({ resultCount }: SearchBarProps) { placeholder="Search books..." value={inputField} onChange={(e) => { + if ( + e.target.value.length > MAX_QUERY_LENGTH && + !tooLongToastOpenRef.current + ) { + tooLongToastOpenRef.current = true; + toast.add({ + description: `Search can be at most ${MAX_QUERY_LENGTH} characters.`, + type: "error", + onClose: () => { + tooLongToastOpenRef.current = false; + }, + }); + } newQueryFromInputRef.current = true; - setInputField(e.target.value); + setInputField(e.target.value.slice(0, MAX_QUERY_LENGTH)); }} /> diff --git a/frontend/app/hooks/useBookSearchParams.test.tsx b/frontend/app/hooks/useBookSearchParams.test.tsx new file mode 100644 index 00000000..14e531a3 --- /dev/null +++ b/frontend/app/hooks/useBookSearchParams.test.tsx @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router"; +import { parsePageNum, useBookSearchParams } from "~/hooks/useBookSearchParams"; +import { MAX_GENRES, MAX_PAGE, MAX_QUERY_LENGTH } from "~/lib/constants"; + +function renderWithUrl(url: string) { + return renderHook(() => useBookSearchParams(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +} + +describe("parsePageNum", () => { + it("falls back to page 1 for missing or invalid values", () => { + expect(parsePageNum(null)).toBe(1); + expect(parsePageNum("0")).toBe(1); + expect(parsePageNum("-3")).toBe(1); + expect(parsePageNum("abc")).toBe(1); + }); + + it("caps the page so the offset fits in a GraphQL Int", () => { + expect(parsePageNum("999999999999")).toBe(MAX_PAGE); + }); +}); + +describe("useBookSearchParams", () => { + it("cuts a long query from the URL", () => { + const { result } = renderWithUrl(`/?q=${"a".repeat(250)}`); + + expect(result.current.query).toBe("a".repeat(MAX_QUERY_LENGTH)); + }); + + it("keeps only the first genres from the URL", () => { + const genres = Array.from({ length: 20 }, (_, i) => `Genre${i}`); + const query = genres.map((genre) => `genres=${genre}`).join("&"); + const { result } = renderWithUrl(`/?${query}`); + + expect(result.current.filters.genres).toEqual(genres.slice(0, MAX_GENRES)); + }); +}); diff --git a/frontend/app/hooks/useBookSearchParams.ts b/frontend/app/hooks/useBookSearchParams.ts index a8f04d35..6547b8d4 100644 --- a/frontend/app/hooks/useBookSearchParams.ts +++ b/frontend/app/hooks/useBookSearchParams.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useSearchParams } from "react-router"; import type { BookFilters } from "~/api/types"; import { parseBookSort } from "~/api/bookSort"; +import { MAX_GENRES, MAX_PAGE, MAX_QUERY_LENGTH } from "~/lib/constants"; function parseCount(value: string | null) { const count = Number(value); @@ -10,7 +11,7 @@ function parseCount(value: string | null) { function parsePageNum(value: string | null) { const page = Number(value); - return Number.isInteger(page) && page >= 1 ? page : 1; + return Number.isInteger(page) && page >= 1 ? Math.min(page, MAX_PAGE) : 1; } const FILTER_KEYS = [ @@ -25,13 +26,14 @@ const FILTER_KEYS = [ function useBookSearchParams() { const [searchParams] = useSearchParams(); - const query = searchParams.get("q") ?? ""; + // Remove any characters above the limit if the user somehow manages to add them + const query = (searchParams.get("q") ?? "").slice(0, MAX_QUERY_LENGTH); const page = parsePageNum(searchParams.get("page")); const sort = parseBookSort(searchParams.get("sort")); // Only update filters when searchParams change to avoid unnecessary re-renders const filters = useMemo((): BookFilters => { - const genres = searchParams.getAll("genres"); + const genres = searchParams.getAll("genres").slice(0, MAX_GENRES); const minPages = parseCount(searchParams.get("minPages")); const maxPages = parseCount(searchParams.get("maxPages")); return { diff --git a/frontend/app/hooks/useFavoriteBooks.ts b/frontend/app/hooks/useFavoriteBooks.ts index 987a62e9..2a83091c 100644 --- a/frontend/app/hooks/useFavoriteBooks.ts +++ b/frontend/app/hooks/useFavoriteBooks.ts @@ -1,5 +1,5 @@ import { gql, type TypedDocumentNode } from "@apollo/client"; -import { useMutation, useQuery } from "@apollo/client/react"; +import { skipToken, useMutation, useQuery } from "@apollo/client/react"; import type { AddFavoriteBookMutation, AddFavoriteBookMutationVariables, @@ -17,7 +17,7 @@ const FAVORITE_BOOKS: TypedDocumentNode< FavoriteBooksQuery, FavoriteBooksQueryVariables > = gql` - query FavoriteBooks($limit: Int, $offset: Int) { + query FavoriteBooks($limit: Int!, $offset: Int!) { favoriteBooks(limit: $limit, offset: $offset) { totalCount books { @@ -70,12 +70,19 @@ function useFavoriteBooks({ enabled?: boolean; page: number; }) { - const { data, loading, error } = useQuery(FAVORITE_BOOKS, { - variables: { limit: BOOK_PAGE_LIMIT, offset: (page - 1) * BOOK_PAGE_LIMIT }, - skip: !enabled, - // Favorites could be added on other pages, so we want to refetch our favorites when the user visits their profile page. - fetchPolicy: "cache-and-network", - }); + const { data, loading, error } = useQuery( + FAVORITE_BOOKS, + enabled + ? { + variables: { + limit: BOOK_PAGE_LIMIT, + offset: (page - 1) * BOOK_PAGE_LIMIT, + }, + // Favorites could be added on other pages, so we want to refetch our favorites when the user visits their profile page. + fetchPolicy: "cache-and-network", + } + : skipToken, + ); const books = data?.favoriteBooks.books.map((book) => { // Extract only the fields we modify, keep the rest in ...rest diff --git a/frontend/app/hooks/useSearchBooks.ts b/frontend/app/hooks/useSearchBooks.ts index 5802873f..29da52b2 100644 --- a/frontend/app/hooks/useSearchBooks.ts +++ b/frontend/app/hooks/useSearchBooks.ts @@ -14,17 +14,17 @@ const SEARCH_BOOKS: TypedDocumentNode< > = gql` query SearchBooks( $title: String - $fuzzy: Boolean + $fuzzy: Boolean! = false $genres: [String!] $language: String $bookFormat: String $minPages: Int $maxPages: Int $minRatings: Int - $sortBy: BookSortField - $sortDirection: SortDirection - $limit: Int - $offset: Int + $sortBy: BookSortField! = ID + $sortDirection: SortDirection! = ASC + $limit: Int! + $offset: Int! ) { searchBooks( title: $title diff --git a/frontend/app/lib/constants.ts b/frontend/app/lib/constants.ts index 93da2a52..6c90edf1 100644 --- a/frontend/app/lib/constants.ts +++ b/frontend/app/lib/constants.ts @@ -7,4 +7,18 @@ const BOOK_PAGE_LIMIT = 12; // The minimum number of ratings of books on the home page. const HOME_MIN_RATINGS = 10_000; -export { MIN_PASSWORD_LENGTH, BOOK_PAGE_LIMIT, HOME_MIN_RATINGS }; +// Make sure these match the limits in backend/src/schema/books/resolvers/Query/searchBooks.ts +const MAX_QUERY_LENGTH = 200; +const MAX_GENRES = 15; + +// GraphQL Int is 32-bit, so the offset (page - 1) * BOOK_PAGE_LIMIT must stay below 2^31. +const MAX_PAGE = Math.floor((2 ** 31 - 1) / BOOK_PAGE_LIMIT); + +export { + MIN_PASSWORD_LENGTH, + BOOK_PAGE_LIMIT, + HOME_MIN_RATINGS, + MAX_QUERY_LENGTH, + MAX_GENRES, + MAX_PAGE, +};