Projects STRLCPY gradejs Commits f7e8b116
🤬
  • feat: rebuild frontend setup (#41)

    * feat: add local startup helpers
    
    * feat: strongly typed public api
    
    * feat: introduce redux as state manager
    
    * fix: review
    
    * fix: trpc typings
    
    * fix: paths
    
    * fix: trpc typings v2
    
    * fix: move cors allowlist to env vars
    
    * fix: add todos
  • Loading...
  • Oleg Klimenko committed with GitHub 2 years ago
    f7e8b116
    1 parent bce2eadc
  • ■ ■ ■ ■ ■
    cli/local_start.sh
    skipped 48 lines
    49 49  AWS_REGION=test PORT=8083 DB_URL=postgres://gradejs:gradejs@localhost:5432/gradejs-public \
    50 50   INTERNAL_API_ORIGIN=http://localhost:8082 SQS_WORKER_QUEUE_URL=/test/frontend-queue \
    51 51   SQS_LOCAL_PORT=29324 AWS_ACCESS_KEY_ID=secret AWS_SECRET_ACCESS_KEY=secret \
     52 + CORS_ALLOWED_ORIGIN=http://localhost:3000 \
    52 53   npm run debug --prefix packages/public-api 2>&1 &
    53 54  API_PID=$!
    54 55   
    skipped 58 lines
  • ■ ■ ■ ■ ■
    package.json
    skipped 20 lines
    21 21   "build:worker": "yarn workspace @gradejs-public/worker run build",
    22 22   "build:public-api": "yarn workspace @gradejs-public/public-api run build",
    23 23   "build:backend": "yarn build:shared && yarn build:public-api && yarn build:worker",
    24  - "dev:start": "bash cli/local_start.sh",
    25  - "dev:worker:syncPackages": "curl -d '{\"type\":\"syncPackageIndex\"}' -H \"Content-Type: application/json\" -X POST http://localhost:8084",
    26  - "dev:worker:syncVulnerabilities": "curl -d '{\"type\":\"syncPackageVulnerabilities\"}' -H \"Content-Type: application/json\" -X POST http://localhost:8084"
     24 + "dev:start": "bash cli/local_start.sh"
    27 25   },
    28 26   "devDependencies": {
    29 27   "@swc/core": "^1.2.233",
    skipped 15 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/package.json
    skipped 5 lines
    6 6   "prettier": "prettier --check ./src",
    7 7   "typecheck": "tsc --project tsconfig.build.json --noEmit",
    8 8   "build": "tsc --project tsconfig.build.json",
    9  - "test": "jest",
     9 + "test": "CORS_ALLOWED_ORIGIN=http://localhost:3000 jest",
    10 10   "start": "node build/index.js",
    11 11   "debug": "node --inspect=9201 build/index.js"
    12 12   },
    skipped 3 lines
    16 16   "license": "MIT",
    17 17   "dependencies": {
    18 18   "@gradejs-public/shared": "^0.1.0",
     19 + "@trpc/server": "^9.26.2",
    19 20   "@types/cors": "^2.8.12",
    20 21   "cors": "^2.8.5",
    21 22   "express": "^4.17.3",
    skipped 2 lines
    24 25   "pg": "^8.7.3",
    25 26   "typeorm": "0.2.45",
    26 27   "typeorm-naming-strategies": "^4.1.0",
    27  - "typescript": "^4.4.3"
     28 + "typescript": "^4.4.3",
     29 + "zod": "^3.17.10"
    28 30   },
    29 31   "devDependencies": {
    30 32   "@types/jest": "^27.4.1",
    skipped 9 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/app.ts
    1 1  import express from 'express';
    2  -import {
    3  - endpointMissingMiddleware,
    4  - errorHandlerMiddleware,
    5  - parseJson,
    6  - cors,
    7  -} from './middleware/common';
    8  -import healthCheckRouter from './healthcheck/router';
    9  -import websiteRouter from './website/router';
     2 +import { createExpressMiddleware } from '@trpc/server/adapters/express';
     3 +import { endpointMissingMiddleware, errorHandlerMiddleware, cors } from './middleware/common';
     4 +import { appRouter, createContext } from './router';
    10 5   
    11 6  export function createApp() {
    12 7   const app = express();
     8 + app.use(cors);
    13 9   
    14  - app.use(cors);
    15  - app.use(parseJson);
    16  - app.use(healthCheckRouter);
    17  - app.use(websiteRouter);
     10 + app.use(
     11 + '/',
     12 + createExpressMiddleware({
     13 + router: appRouter,
     14 + createContext,
     15 + })
     16 + );
    18 17   
    19 18   // Error handling
    20 19   app.use(endpointMissingMiddleware);
    skipped 5 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/healthcheck/router.test.ts
    1  -import { createSupertestApi } from '@gradejs-public/test-utils';
    2  -import { createApp } from '../app';
    3  - 
    4  -const api = createSupertestApi(createApp);
    5  - 
    6  -describe('routes / heathCheck', () => {
    7  - it('should return valid response', async () => {
    8  - await api.get('/').send().expect(200);
    9  - });
    10  - 
    11  - it('should return not found error', async () => {
    12  - const response = await api.get('/any-invalid-route').send().expect(404);
    13  - 
    14  - expect(response.body).toMatchObject({
    15  - error: {
    16  - code: 404,
    17  - message: 'Not Found',
    18  - },
    19  - });
    20  - });
    21  -});
    22  - 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/healthcheck/router.ts
    1  -import { Router } from 'express';
    2  -import { respond } from '../middleware/response';
    3  - 
    4  -const router = Router();
    5  - 
    6  -router.get('/', (_, res) => {
    7  - respond(res, `gradejs-public-api`);
    8  -});
    9  - 
    10  -export default router;
    11  - 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/middleware/common.ts
    1  -import { ValidationError } from 'joi';
     1 +import { ZodError } from 'zod';
    2 2  import createCorsMiddleware from 'cors';
    3  -import express, { Request, Response, NextFunction } from 'express';
     3 +import { Request, Response, NextFunction } from 'express';
    4 4  import { NotFoundError, respondWithError } from './response';
     5 +import { getCorsAllowedOrigins } from '@gradejs-public/shared';
    5 6   
    6  -// TODO: add whitelist origins
    7  -export const cors = createCorsMiddleware({ maxAge: 1800 });
    8  -export const parseJson = express.json();
     7 +const originAllowList = getCorsAllowedOrigins();
     8 +export const cors = createCorsMiddleware({
     9 + maxAge: 1800,
     10 + origin: function (origin, callback) {
     11 + if (origin && originAllowList.includes(origin)) {
     12 + callback(null, true);
     13 + } else {
     14 + callback(new Error('Not allowed by CORS'));
     15 + }
     16 + },
     17 +});
    9 18   
    10 19  /**
    11 20   * Handles any unknown routes by default
    skipped 13 lines
    25 34   _next: NextFunction
    26 35  ) {
    27 36   // Log only useful errors
    28  - if (!(error instanceof NotFoundError) && !(error instanceof ValidationError)) {
     37 + if (!(error instanceof NotFoundError) && !(error instanceof ZodError)) {
    29 38   // TODO: add logger
    30 39   console.error(error, req);
    31 40   }
    skipped 1 lines
    33 42   respondWithError(res, error);
    34 43  }
    35 44   
    36  -export function wrapTryCatch(func: (req: Request, res: Response) => Promise<void>) {
    37  - return async (req: Request, res: Response, next: NextFunction) => {
    38  - try {
    39  - await func(req, res);
    40  - } catch (error) {
    41  - next(error);
    42  - }
    43  - };
    44  -}
    45  - 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/middleware/response.ts
    1 1  import { Response } from 'express';
    2  -import { ValidationError } from 'joi';
     2 +import { ZodError } from 'zod';
    3 3   
    4 4  export type ApiResponse<TData = undefined> = { data: TData } | { error: ApiDetailedError };
    5 5   
    skipped 26 lines
    32 32   error.message = err.message;
    33 33   }
    34 34   
    35  - if (err instanceof ValidationError) {
    36  - const details = err.details[0];
     35 + if (err instanceof ZodError) {
     36 + const details = err.errors[0];
    37 37   error.code = 400;
    38 38   error.message = details.message || err.message;
    39  - error.type = details.type;
     39 + error.type = details.code;
    40 40   error.param = details.path.join('.');
    41 41   }
    42 42   
    skipped 6 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/middleware/validation.ts
    1  -import { Request } from 'express';
    2  -import j, { AnySchema, StringSchema } from 'joi';
    3  - 
    4  -/**
    5  - * Usefull wrapper for better validation typing and good-looking code (body json payload)
    6  - * @example
    7  - * // POST /login { email: '...' }
    8  - * const email = parseBody(req, 'email', joi.string().email())
    9  - */
    10  -export function parseBody(req: Request, param: string, schema: StringSchema): string;
    11  -export function parseBody(req: Request, param: string, schema: AnySchema) {
    12  - return parse(req, 'body', param, schema);
    13  -}
    14  - 
    15  -/**
    16  - * Usefull wrapper for better validation typing and good-looking code (query params)
    17  - * @example
    18  - * // GET /devices?token=xxx
    19  - * const token = parseQuery(req, 'token', joi.string().email())
    20  - */
    21  -export function parseQuery(req: Request, param: string, schema: StringSchema): string;
    22  -export function parseQuery(req: Request, param: string, schema: AnySchema) {
    23  - return parse(req, 'query', param, schema);
    24  -}
    25  - 
    26  -/**
    27  - * Usefull wrapper for better validation typing and good-looking code (slug)
    28  - * @example
    29  - * // GET /devices/:slug
    30  - * const slug = parseSlug(req, 'slug', joi.string().email())
    31  - */
    32  -export function parseSlug(req: Request, param: string, schema: StringSchema): string;
    33  -export function parseSlug(req: Request, param: string, schema: AnySchema) {
    34  - return parse(req, 'params', param, schema);
    35  -}
    36  - 
    37  -function parse(req: Request, where: string, param: string, schema: AnySchema): unknown {
    38  - const extendedSchema = j
    39  - .object({
    40  - [where]: j
    41  - .object({
    42  - [param]: schema,
    43  - })
    44  - .unknown(true),
    45  - })
    46  - .unknown(true);
    47  - 
    48  - const { value, error } = extendedSchema.validate(req);
    49  - 
    50  - if (error) {
    51  - throw error;
    52  - }
    53  - 
    54  - return value[where][param];
    55  -}
    56  - 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/router.test.ts
     1 +import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';
     2 +import {
     3 + internalApi,
     4 + PackageMetadata,
     5 + PackageVulnerability,
     6 + WebPage,
     7 + WebPagePackage,
     8 +} from '@gradejs-public/shared';
     9 +import {
     10 + createSupertestApi,
     11 + useDatabaseConnection,
     12 + useTransactionalTesting,
     13 +} from '@gradejs-public/test-utils';
     14 +import { getRepository } from 'typeorm';
     15 +import { createApp } from './app';
     16 + 
     17 +useDatabaseConnection();
     18 +useTransactionalTesting();
     19 + 
     20 +const api = createSupertestApi(createApp);
     21 + 
     22 +describe('routes / heathCheck', () => {
     23 + it('should return valid response', async () => {
     24 + await api.get('/healthcheck').set('Origin', 'http://localhost:3000').send().expect(200);
     25 + });
     26 + 
     27 + it('should return not found error', async () => {
     28 + const response = await api
     29 + .get('/any-invalid-route')
     30 + .set('Origin', 'http://localhost:3000')
     31 + .send()
     32 + .expect(404);
     33 + 
     34 + expect(response.body).toMatchObject({
     35 + error: {
     36 + code: TRPC_ERROR_CODES_BY_KEY.NOT_FOUND,
     37 + },
     38 + });
     39 + });
     40 +});
     41 + 
     42 +describe('routes / website', () => {
     43 + it('should initiate webpage parsing', async () => {
     44 + const siteUrl = 'https://example.com/' + Math.random().toString();
     45 + 
     46 + const initiateUrlProcessingInternalMock = jest.spyOn(internalApi, 'initiateUrlProcessing');
     47 + 
     48 + initiateUrlProcessingInternalMock.mockImplementation((url) =>
     49 + Promise.resolve({
     50 + url,
     51 + status: 'in-progress',
     52 + } as internalApi.Website)
     53 + );
     54 + 
     55 + const response = await api
     56 + .post('/requestParseWebsite')
     57 + .set('Origin', 'http://localhost:3000')
     58 + .send(JSON.stringify(siteUrl))
     59 + .expect(200);
     60 + const webpage = await getRepository(WebPage).findOne({ url: siteUrl });
     61 + 
     62 + expect(initiateUrlProcessingInternalMock).toHaveBeenCalledTimes(1);
     63 + expect(initiateUrlProcessingInternalMock).toHaveBeenCalledWith(siteUrl);
     64 + expect(response.body).toMatchObject({
     65 + result: {
     66 + data: {
     67 + id: expect.anything(),
     68 + url: siteUrl,
     69 + hostname: 'example.com',
     70 + status: 'pending',
     71 + },
     72 + },
     73 + });
     74 + 
     75 + expect(webpage).toMatchObject({
     76 + url: siteUrl,
     77 + hostname: 'example.com',
     78 + status: 'pending',
     79 + });
     80 + });
     81 + 
     82 + it('should return cached website by a hostname', async () => {
     83 + const hostname = Math.random().toString() + 'example.com';
     84 + const url = `https://${hostname}/`;
     85 + 
     86 + const fetchUrlPackagesMock = jest.spyOn(internalApi, 'fetchUrlPackages');
     87 + 
     88 + // Populate
     89 + const webpageInsert = await getRepository(WebPage).insert({
     90 + url,
     91 + hostname,
     92 + status: WebPage.Status.Processed,
     93 + });
     94 + 
     95 + const packageInsert = await getRepository(WebPagePackage).insert({
     96 + latestUrl: url,
     97 + hostname,
     98 + packageName: 'react',
     99 + possiblePackageVersions: ['17.0.2'],
     100 + packageVersionRange: '17.0.2',
     101 + });
     102 + 
     103 + const packageMetadataInsert = await getRepository(PackageMetadata).insert({
     104 + name: 'react',
     105 + latestVersion: '18.0.0',
     106 + monthlyDownloads: 100,
     107 + updateSeq: 5,
     108 + license: 'MIT',
     109 + });
     110 + 
     111 + await getRepository(PackageVulnerability).insert({
     112 + packageName: 'react',
     113 + packageVersionRange: '>=17.0.0 <18.0.0',
     114 + osvId: 'GRJS-test-id',
     115 + osvData: {
     116 + schema_version: '1.2.0',
     117 + id: 'GRJS-test-id',
     118 + summary: 'Test summary',
     119 + database_specific: {
     120 + severity: 'HIGH',
     121 + },
     122 + },
     123 + });
     124 + 
     125 + const response = await api
     126 + .post('/syncWebsite')
     127 + .set('Origin', 'http://localhost:3000')
     128 + .send(JSON.stringify(hostname))
     129 + .expect(200);
     130 + 
     131 + expect(fetchUrlPackagesMock).toHaveBeenCalledTimes(0);
     132 + expect(response.body).toMatchObject({
     133 + result: {
     134 + data: {
     135 + webpages: webpageInsert.generatedMaps,
     136 + packages: [
     137 + {
     138 + ...packageInsert.generatedMaps[0],
     139 + registryMetadata: packageMetadataInsert.generatedMaps[0],
     140 + },
     141 + ],
     142 + vulnerabilities: {
     143 + react: [
     144 + {
     145 + affectedPackageName: 'react',
     146 + affectedVersionRange: '>=17.0.0 <18.0.0',
     147 + osvId: 'GRJS-test-id',
     148 + detailsUrl: `https://github.com/advisories/GRJS-test-id`,
     149 + summary: 'Test summary',
     150 + severity: 'HIGH',
     151 + },
     152 + ],
     153 + },
     154 + },
     155 + },
     156 + });
     157 + });
     158 + 
     159 + it('should sync pending webpages', async () => {
     160 + const hostname = Math.random().toString() + 'example.com';
     161 + const siteUrl = `https://${hostname}/`;
     162 + 
     163 + // Populate
     164 + const webpageInsert = await getRepository(WebPage).insert({
     165 + url: siteUrl,
     166 + hostname,
     167 + status: WebPage.Status.Pending,
     168 + });
     169 + 
     170 + const fetchUrlPackagesMock = jest.spyOn(internalApi, 'fetchUrlPackages');
     171 + 
     172 + fetchUrlPackagesMock.mockImplementation((url) =>
     173 + Promise.resolve({
     174 + id: 0,
     175 + updatedAt: '1',
     176 + createdAt: '1',
     177 + url,
     178 + status: 'ready',
     179 + detectedPackages: [
     180 + {
     181 + name: 'react',
     182 + versionRange: '17.0.2',
     183 + possibleVersions: ['17.0.2'],
     184 + approximateSize: 1337,
     185 + },
     186 + {
     187 + name: 'object-assign',
     188 + versionRange: '4.1.0 - 4.1.1',
     189 + possibleVersions: ['4.1.0', '4.1.1'],
     190 + approximateSize: 42,
     191 + },
     192 + ],
     193 + } as internalApi.Website)
     194 + );
     195 + 
     196 + const response = await api
     197 + .post('/syncWebsite')
     198 + .set('Origin', 'http://localhost:3000')
     199 + .send(JSON.stringify(hostname))
     200 + .expect(200);
     201 + expect(fetchUrlPackagesMock).toHaveBeenCalledTimes(1);
     202 + expect(fetchUrlPackagesMock).toHaveBeenCalledWith(siteUrl);
     203 + 
     204 + expect(response.body).toMatchObject({
     205 + result: {
     206 + data: {
     207 + webpages: [
     208 + {
     209 + ...webpageInsert.generatedMaps.at(0),
     210 + status: WebPage.Status.Processed,
     211 + updatedAt: expect.anything(),
     212 + },
     213 + ],
     214 + packages: [
     215 + {
     216 + latestUrl: siteUrl,
     217 + hostname,
     218 + packageName: 'react',
     219 + possiblePackageVersions: ['17.0.2'],
     220 + packageVersionRange: '17.0.2',
     221 + packageMetadata: {
     222 + approximateByteSize: 1337,
     223 + },
     224 + },
     225 + {
     226 + latestUrl: siteUrl,
     227 + hostname,
     228 + packageName: 'object-assign',
     229 + possiblePackageVersions: ['4.1.0', '4.1.1'],
     230 + packageVersionRange: '4.1.0 - 4.1.1',
     231 + packageMetadata: {
     232 + approximateByteSize: 42,
     233 + },
     234 + },
     235 + ],
     236 + vulnerabilities: {},
     237 + },
     238 + },
     239 + });
     240 + });
     241 +});
     242 + 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/router.ts
     1 +import * as trpc from '@trpc/server';
     2 +// See also: https://colinhacks.com/essays/painless-typesafety
     3 +import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
     4 +import { z, ZodError } from 'zod';
     5 +import { NotFoundError } from './middleware/response';
     6 +import {
     7 + getPackagesByHostname,
     8 + getWebPagesByHostname,
     9 + requestWebPageParse,
     10 + syncWebPage,
     11 +} from './website/service';
     12 +import { getAffectingVulnerabilities } from './vulnerabilities/vulnerabilities';
     13 +import { SerializableEntity, toSerializable } from '@gradejs-public/shared';
     14 + 
     15 +const hostnameRe =
     16 + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/;
     17 + 
     18 +// created for each request
     19 +export const createContext = (_: CreateExpressContextOptions) => ({}); // no context
     20 +type Context = trpc.inferAsyncReturnType<typeof createContext>;
     21 + 
     22 +export namespace Api {
     23 + export type WebPage = SerializableEntity<
     24 + Awaited<ReturnType<typeof getWebPagesByHostname>>[number]
     25 + >;
     26 + export type WebPagePackage = SerializableEntity<
     27 + Awaited<ReturnType<typeof getPackagesByHostname>>[number]
     28 + >;
     29 + export type WebSiteParseResult = SerializableEntity<
     30 + Awaited<ReturnType<typeof requestWebPageParse>>
     31 + >;
     32 + export type Vulnerability = SerializableEntity<
     33 + Awaited<ReturnType<typeof getAffectingVulnerabilities>>[string][number]
     34 + >;
     35 +}
     36 + 
     37 +export const appRouter = trpc
     38 + .router<Context>()
     39 + .query('healthcheck', {
     40 + resolve() {
     41 + return 'gradejs-public-api';
     42 + },
     43 + })
     44 + .mutation('syncWebsite', {
     45 + input: z.string().regex(hostnameRe),
     46 + async resolve({ input: hostname }) {
     47 + const webpages = await getWebPagesByHostname(hostname);
     48 + 
     49 + if (webpages.length === 0) {
     50 + throw new NotFoundError();
     51 + }
     52 + 
     53 + await Promise.all(webpages.map(syncWebPage));
     54 + const packages = await getPackagesByHostname(hostname);
     55 + const vulnerabilities = await getAffectingVulnerabilities(packages);
     56 + 
     57 + return {
     58 + webpages: webpages.map(toSerializable),
     59 + packages: packages.map(toSerializable),
     60 + vulnerabilities,
     61 + };
     62 + },
     63 + })
     64 + .mutation('requestParseWebsite', {
     65 + input: z.string().url(),
     66 + async resolve({ input: url }) {
     67 + return toSerializable(await requestWebPageParse(url));
     68 + },
     69 + })
     70 + .formatError(({ shape, error }) => {
     71 + // TODO: proper reporting
     72 + return {
     73 + ...shape,
     74 + data: {
     75 + ...shape.data,
     76 + zodError:
     77 + error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
     78 + ? error.cause.flatten()
     79 + : null,
     80 + },
     81 + };
     82 + });
     83 + 
     84 +// export type definition of API
     85 +export type AppRouter = typeof appRouter;
     86 + 
  • ■ ■ ■ ■ ■
    packages/public-api/src/vulnerabilities/vulnerabilities.ts
    1 1  import {
    2 2   GithubAdvisoryDatabaseSpecific,
    3  - GithubAdvisorySeverity,
     3 + PackageVulnerabilityData,
    4 4   PackageVulnerability,
    5 5   WebPagePackage,
    6 6  } from '@gradejs-public/shared';
    7 7  import { getRepository } from 'typeorm';
    8 8  import semver from 'semver';
    9 9   
    10  -export type ApiPackageVulnerabilityData = {
    11  - affectedPackageName: string;
    12  - affectedVersionRange: string;
    13  - osvId: string;
    14  - detailsUrl: string;
    15  - summary?: string;
    16  - severity?: GithubAdvisorySeverity;
    17  -};
    18  - 
    19 10  export async function getVulnerabilitiesByPackageNames(packageNames: string[]) {
    20 11   if (packageNames.length === 0) {
    21 12   return [];
    skipped 7 lines
    29 20  }
    30 21   
    31 22  export async function getAffectingVulnerabilities(packages: WebPagePackage[]) {
    32  - const affectingVulnerabilitiesByPackage: Record<string, ApiPackageVulnerabilityData[]> = {};
     23 + const affectingVulnerabilitiesByPackage: Record<string, PackageVulnerabilityData[]> = {};
    33 24   if (!packages.length) {
    34 25   return affectingVulnerabilitiesByPackage;
    35 26   }
    skipped 42 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/website/router.test.ts
    1  -import {
    2  - internalApi,
    3  - PackageMetadata,
    4  - PackageVulnerability,
    5  - WebPage,
    6  - WebPagePackage,
    7  -} from '@gradejs-public/shared';
    8  -import {
    9  - createSupertestApi,
    10  - useDatabaseConnection,
    11  - useTransactionalTesting,
    12  -} from '@gradejs-public/test-utils';
    13  -import { getRepository } from 'typeorm';
    14  -import { createApp } from '../app';
    15  - 
    16  -useDatabaseConnection();
    17  -useTransactionalTesting();
    18  - 
    19  -const api = createSupertestApi(createApp);
    20  - 
    21  -describe('routes / website', () => {
    22  - it('should initiate webpage parsing', async () => {
    23  - const siteUrl = 'https://example.com/' + Math.random().toString();
    24  - 
    25  - const initiateUrlProcessingInternalMock = jest.spyOn(internalApi, 'initiateUrlProcessing');
    26  - 
    27  - initiateUrlProcessingInternalMock.mockImplementation((url) =>
    28  - Promise.resolve({
    29  - url,
    30  - status: 'in-progress',
    31  - } as internalApi.Website)
    32  - );
    33  - 
    34  - const response = await api.post('/webpage').send({ url: siteUrl }).expect(200);
    35  - const webpage = await getRepository(WebPage).findOne({ url: siteUrl });
    36  - 
    37  - expect(initiateUrlProcessingInternalMock).toHaveBeenCalledTimes(1);
    38  - expect(initiateUrlProcessingInternalMock).toHaveBeenCalledWith(siteUrl);
    39  - expect(response.body).toMatchObject({
    40  - data: {
    41  - id: expect.anything(),
    42  - url: siteUrl,
    43  - hostname: 'example.com',
    44  - status: 'pending',
    45  - },
    46  - });
    47  - 
    48  - expect(webpage).toMatchObject({
    49  - url: siteUrl,
    50  - hostname: 'example.com',
    51  - status: 'pending',
    52  - });
    53  - });
    54  - 
    55  - it('should return cached website by a hostname', async () => {
    56  - const hostname = Math.random().toString() + 'example.com';
    57  - const url = `https://${hostname}/`;
    58  - 
    59  - const fetchUrlPackagesMock = jest.spyOn(internalApi, 'fetchUrlPackages');
    60  - 
    61  - // Populate
    62  - const webpageInsert = await getRepository(WebPage).insert({
    63  - url,
    64  - hostname,
    65  - status: WebPage.Status.Processed,
    66  - });
    67  - 
    68  - const packageInsert = await getRepository(WebPagePackage).insert({
    69  - latestUrl: url,
    70  - hostname,
    71  - packageName: 'react',
    72  - possiblePackageVersions: ['17.0.2'],
    73  - packageVersionRange: '17.0.2',
    74  - });
    75  - 
    76  - const packageMetadataInsert = await getRepository(PackageMetadata).insert({
    77  - name: 'react',
    78  - latestVersion: '18.0.0',
    79  - monthlyDownloads: 100,
    80  - updateSeq: 5,
    81  - license: 'MIT',
    82  - });
    83  - 
    84  - await getRepository(PackageVulnerability).insert({
    85  - packageName: 'react',
    86  - packageVersionRange: '>=17.0.0 <18.0.0',
    87  - osvId: 'GRJS-test-id',
    88  - osvData: {
    89  - schema_version: '1.2.0',
    90  - id: 'GRJS-test-id',
    91  - summary: 'Test summary',
    92  - database_specific: {
    93  - severity: 'HIGH',
    94  - },
    95  - },
    96  - });
    97  - 
    98  - const response = await api.get(`/website/${hostname}`).expect(200);
    99  - 
    100  - expect(fetchUrlPackagesMock).toHaveBeenCalledTimes(0);
    101  - expect(response.body).toMatchObject({
    102  - data: {
    103  - webpages: webpageInsert.generatedMaps,
    104  - packages: [
    105  - {
    106  - ...packageInsert.generatedMaps[0],
    107  - registryMetadata: packageMetadataInsert.generatedMaps[0],
    108  - },
    109  - ],
    110  - vulnerabilities: {
    111  - react: [
    112  - {
    113  - affectedPackageName: 'react',
    114  - affectedVersionRange: '>=17.0.0 <18.0.0',
    115  - osvId: 'GRJS-test-id',
    116  - detailsUrl: `https://github.com/advisories/GRJS-test-id`,
    117  - summary: 'Test summary',
    118  - severity: 'HIGH',
    119  - },
    120  - ],
    121  - },
    122  - },
    123  - });
    124  - });
    125  - 
    126  - it('should sync pending webpages', async () => {
    127  - const hostname = Math.random().toString() + 'example.com';
    128  - const siteUrl = `https://${hostname}/`;
    129  - 
    130  - // Populate
    131  - const webpageInsert = await getRepository(WebPage).insert({
    132  - url: siteUrl,
    133  - hostname,
    134  - status: WebPage.Status.Pending,
    135  - });
    136  - 
    137  - const fetchUrlPackagesMock = jest.spyOn(internalApi, 'fetchUrlPackages');
    138  - 
    139  - fetchUrlPackagesMock.mockImplementation((url) =>
    140  - Promise.resolve({
    141  - id: 0,
    142  - updatedAt: '1',
    143  - createdAt: '1',
    144  - url,
    145  - status: 'ready',
    146  - detectedPackages: [
    147  - {
    148  - name: 'react',
    149  - versionRange: '17.0.2',
    150  - possibleVersions: ['17.0.2'],
    151  - approximateSize: 1337,
    152  - },
    153  - {
    154  - name: 'object-assign',
    155  - versionRange: '4.1.0 - 4.1.1',
    156  - possibleVersions: ['4.1.0', '4.1.1'],
    157  - approximateSize: 42,
    158  - },
    159  - ],
    160  - } as internalApi.Website)
    161  - );
    162  - 
    163  - const response = await api.get(`/website/${hostname}`).expect(200);
    164  - expect(fetchUrlPackagesMock).toHaveBeenCalledTimes(1);
    165  - expect(fetchUrlPackagesMock).toHaveBeenCalledWith(siteUrl);
    166  - 
    167  - expect(response.body).toMatchObject({
    168  - data: {
    169  - webpages: [
    170  - {
    171  - ...webpageInsert.generatedMaps.at(0),
    172  - status: WebPage.Status.Processed,
    173  - updatedAt: expect.anything(),
    174  - },
    175  - ],
    176  - packages: [
    177  - {
    178  - latestUrl: siteUrl,
    179  - hostname,
    180  - packageName: 'react',
    181  - possiblePackageVersions: ['17.0.2'],
    182  - packageVersionRange: '17.0.2',
    183  - packageMetadata: {
    184  - approximateByteSize: 1337,
    185  - },
    186  - },
    187  - {
    188  - latestUrl: siteUrl,
    189  - hostname,
    190  - packageName: 'object-assign',
    191  - possiblePackageVersions: ['4.1.0', '4.1.1'],
    192  - packageVersionRange: '4.1.0 - 4.1.1',
    193  - packageMetadata: {
    194  - approximateByteSize: 42,
    195  - },
    196  - },
    197  - ],
    198  - vulnerabilities: {},
    199  - },
    200  - });
    201  - });
    202  -});
    203  - 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/website/router.ts
    1  -import { Router } from 'express';
    2  -import j from 'joi';
    3  -import { wrapTryCatch } from '../middleware/common';
    4  -import { NotFoundError, respond } from '../middleware/response';
    5  -import { parseBody, parseSlug } from '../middleware/validation';
    6  -import {
    7  - getPackagesByHostname,
    8  - getWebPagesByHostname,
    9  - requestWebPageParse,
    10  - syncWebPage,
    11  -} from './service';
    12  -import { getAffectingVulnerabilities } from '../vulnerabilities/vulnerabilities';
    13  - 
    14  -const router = Router();
    15  - 
    16  -router.get(
    17  - '/website/:hostname',
    18  - wrapTryCatch(async (req, res) => {
    19  - const hostname = parseSlug(req, 'hostname', j.string().required().hostname());
    20  - const webpages = await getWebPagesByHostname(hostname);
    21  - 
    22  - if (webpages.length === 0) {
    23  - throw new NotFoundError();
    24  - }
    25  - 
    26  - // Sync webpages if status is not processed
    27  - await Promise.all(webpages.map((webpage) => syncWebPage(webpage)));
    28  - 
    29  - const packages = await getPackagesByHostname(hostname);
    30  - const vulnerabilities = await getAffectingVulnerabilities(packages);
    31  - 
    32  - respond(res, { webpages, packages, vulnerabilities });
    33  - })
    34  -);
    35  - 
    36  -router.post(
    37  - '/webpage',
    38  - wrapTryCatch(async (req, res) => {
    39  - const urlOptions = { scheme: ['http', 'https'] };
    40  - const url = parseBody(req, 'url', j.string().required().uri(urlOptions));
    41  - 
    42  - const webpage = await requestWebPageParse(url);
    43  - 
    44  - respond(res, webpage);
    45  - })
    46  -);
    47  - 
    48  -export default router;
    49  - 
  • ■ ■ ■ ■ ■ ■
    packages/shared/src/database/entities/packageVulnerability.ts
    skipped 90 lines
    91 91   Critical = 'CRITICAL',
    92 92  }
    93 93   
     94 +export type PackageVulnerabilityData = {
     95 + affectedPackageName: string;
     96 + affectedVersionRange: string;
     97 + osvId: string;
     98 + detailsUrl: string;
     99 + summary?: string;
     100 + severity?: GithubAdvisorySeverity;
     101 +};
     102 + 
    94 103  @Entity({ name: 'package_vulnerability' })
    95 104  @Index(['packageName', 'osvId'], { unique: true })
    96 105  export class PackageVulnerability extends BaseEntity {
    skipped 16 lines
  • ■ ■ ■ ■ ■
    packages/shared/src/index.ts
    skipped 5 lines
    6 6   
    7 7  export * from './utils/aws';
    8 8  export * from './utils/env';
     9 +export * from './utils/types';
    9 10   
    10 11  export * as internalApi from './internalApi/api';
    11 12   
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/shared/src/internalApi/api.ts
    skipped 78 lines
    79 79   }
    80 80   }
    81 81   
     82 + // console.log('Request to internal API: ', requestUrl.toString(), requestInit);
     83 + 
    82 84   return fetch(requestUrl.toString(), requestInit)
    83 85   .then((response) => response.json())
    84 86   .then((json: any) => {
    skipped 11 lines
  • ■ ■ ■ ■ ■ ■
    packages/shared/src/utils/env.ts
    skipped 13 lines
    14 14   
    15 15   // Internal
    16 16   InternalApiOrigin = 'INTERNAL_API_ORIGIN',
     17 + 
     18 + CorsAllowedOrigin = 'CORS_ALLOWED_ORIGIN',
    17 19  }
    18 20   
    19  -export const getNodeEnv = () => getEnvUnsafe(Env.Node) ?? 'development';
     21 +export const getNodeEnv = () => {
     22 + const env = getEnvUnsafe(Env.Node);
     23 + if (!env || !['production', 'staging', 'development', 'test'].includes(env)) {
     24 + return 'development';
     25 + }
     26 + return env as 'production' | 'staging' | 'development' | 'test';
     27 +};
    20 28  export const getPort = (defaultPort: number) => Number(getEnv(Env.Port, defaultPort.toString()));
    21 29   
    22 30  export const isProduction = () => getNodeEnv() === 'production';
    skipped 5 lines
    28 36  export const getSqsLocalPort = () => Number(getEnv(Env.SqsLocalPort, '0'));
    29 37   
    30 38  export const getGitHubAccessToken = () => getEnv(Env.GitHubAccessToken);
     39 + 
     40 +export const getCorsAllowedOrigins = () => {
     41 + const origins = getEnvUnsafe(Env.CorsAllowedOrigin);
     42 + if (!origins) {
     43 + return [];
     44 + }
     45 + 
     46 + return origins.split(',').map((origin) => origin.trim());
     47 +};
    31 48   
    32 49  export function getEnv(name: string, defaultValue?: string) {
    33 50   const value = process.env[name];
    skipped 16 lines
  • ■ ■ ■ ■ ■ ■
    packages/shared/src/utils/types.ts
     1 +type ExcludeKeysWithTypeOf<T, V> = {
     2 + [K in keyof T]: Exclude<T[K], undefined> extends V ? never : K;
     3 +}[keyof T];
     4 + 
     5 +type ReplaceUnserializable<T extends object> = {
     6 + [key in keyof T]: T[key] extends Date
     7 + ? string
     8 + : T[key] extends Date | undefined
     9 + ? string | undefined
     10 + : T[key] extends Function
     11 + ? never
     12 + : T[key];
     13 +};
     14 + 
     15 +export type SerializableEntity<T extends object> = Pick<
     16 + ReplaceUnserializable<T>,
     17 + ExcludeKeysWithTypeOf<ReplaceUnserializable<T>, never>
     18 +>;
     19 + 
     20 +export function toSerializable<T extends object>(input: T): SerializableEntity<T> {
     21 + return input as SerializableEntity<T>;
     22 +}
     23 + 
  • ■ ■ ■ ■ ■
    packages/web/package.json
    skipped 49 lines
    50 50   "webpack-dev-server": "^4.2.1"
    51 51   },
    52 52   "dependencies": {
     53 + "@reduxjs/toolkit": "^1.8.3",
     54 + "@trpc/client": "^9.27.0",
     55 + "@types/lodash.memoize": "^4.1.7",
    53 56   "clsx": "^1.1.1",
     57 + "lodash.memoize": "^4.1.2",
    54 58   "plausible-tracker": "^0.3.8",
    55 59   "react": "^17.0.2",
    56 60   "react-dom": "^17.0.2",
    57 61   "react-ga": "^3.3.0",
    58 62   "react-hook-form": "^7.15.3",
     63 + "react-redux": "^8.0.2",
    59 64   "react-router-dom": "^6.3.0",
    60 65   "semver": "^7.3.7"
    61 66   }
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/App.tsx
    1 1  import React from 'react';
    2 2  import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
    3  -import HomePage from './pages/HomePage';
    4  -import WebsiteHostnamePage from './pages/WebsiteHostnamePage';
     3 +import { HomePage } from './pages/Home';
     4 +import { WebsiteResultsPage } from './pages/WebsiteResults';
    5 5  import { initAnalytics } from '../services/analytics';
    6 6  const locationChangeHandler = initAnalytics();
    7 7   
    skipped 6 lines
    14 14   * <App />
    15 15   * </BrowserRouter>
    16 16   */
    17  -export default function App() {
     17 +export function App() {
    18 18   const location = useLocation();
    19 19   locationChangeHandler(location.pathname);
    20 20   return (
    21 21   <Routes>
    22 22   <Route index element={<HomePage />} />
    23  - <Route path='/w/:hostname' element={<WebsiteHostnamePage />} />
     23 + <Route path='/w/:hostname' element={<WebsiteResultsPage />} />
    24 24   <Route path='*' element={<Navigate replace to='/' />} />
    25 25   </Routes>
    26 26   );
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/layouts/Filters/Filters.tsx
    skipped 8 lines
    9 9  import { trackCustomEvent } from '../../../services/analytics';
    10 10   
    11 11  export type Props = {
    12  - onSubmit: SubmitHandler<FormData>;
     12 + onSubmit: SubmitHandler<FiltersState>;
    13 13  };
    14 14   
    15  -export type FormData = {
     15 +export type FiltersState = {
    16 16   filter: 'all' | 'outdated' | 'vulnerable' | 'name';
    17 17   sort: 'name' | 'size' | 'severity' | 'importDepth' | 'packagePopularity' | 'confidenceScore';
    18 18   filterPackageName?: string;
    19 19  };
    20 20   
    21  -export const DefaultFiltersAndSorters: FormData = {
     21 +export const DefaultFiltersAndSorters: FiltersState = {
    22 22   filter: 'all',
    23  - sort: 'severity',
     23 + sort: 'packagePopularity',
    24 24  };
    25 25   
    26 26  export default function Filters({ onSubmit }: Props) {
    27  - const { register, handleSubmit, reset, watch } = useForm<FormData>({
     27 + const { register, handleSubmit, reset, watch } = useForm<FiltersState>({
    28 28   defaultValues: DefaultFiltersAndSorters,
    29 29   });
    30 30   const watchFilterByName = watch('filter');
    skipped 82 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/layouts/Website/Website.tsx
    skipped 4 lines
    5 5  import { Header, Package, Section, PackageSkeleton } from 'components/ui';
    6 6  import { Grid, Lines } from 'components/icons';
    7 7  import styles from './Website.module.scss';
    8  -import { DetectedPackageData } from '../../ui/Package/Package';
    9  -import { PackageVulnerabilityData } from '../../ui/Vulnerability/Vulnerability';
    10  -import Filters, { FormData } from '../Filters/Filters';
     8 +import Filters, { FiltersState } from '../Filters/Filters';
    11 9  import TagBadge from '../../ui/TagBadge/TagBadge';
    12 10  import { trackCustomEvent } from '../../../services/analytics';
     11 +import { Api } from '../../../services/apiClient';
    13 12   
    14 13  // TODO: Add plashechka
    15 14  export type Props = {
    skipped 3 lines
    19 18   // title: string;
    20 19   // icon: string;
    21 20   // }>;
    22  - packages: DetectedPackageData[];
    23  - vulnerabilities: Record<string, PackageVulnerabilityData[]>;
    24  - webpages: Array<{
    25  - status: string;
    26  - }>;
    27  - onFiltersApply: SubmitHandler<FormData>;
     21 + isLoading: boolean;
     22 + isPending: boolean;
     23 + packages: Api.WebPagePackage[];
     24 + vulnerabilities: Record<string, Api.Vulnerability[]>;
     25 + webpages: Api.WebPage[];
     26 + onFiltersApply: SubmitHandler<FiltersState>;
    28 27  };
    29 28   
    30 29  export default function Website({
    31 30   host,
     31 + isLoading,
     32 + isPending,
    32 33   packages,
    33 34   webpages,
    34 35   vulnerabilities,
    35 36   onFiltersApply,
    36 37  }: Props) {
    37 38   const [view, setView] = useState<'grid' | 'lines'>('grid');
    38  - const isPending = !!webpages.find((item) => item.status === 'pending');
    39  - const isLoading = packages.length === 0;
    40 39   
    41 40   return (
    42 41   <>
    skipped 1 lines
    44 43   <Section>
    45 44   <h1 className={styles.heading}>{host}</h1>
    46 45   
    47  - {webpages.length > 0 &&
    48  - (isPending ? (
    49  - <div className={clsx(styles.disclaimer, styles.disclaimerLoading)}>
    50  - GradeJS is currently processing this website. <br />
    51  - It may take a few minutes and depends on the number of JavaScript files and their
    52  - size.
    53  - </div>
    54  - ) : (
     46 + {isPending ? (
     47 + <div className={clsx(styles.disclaimer, styles.disclaimerLoading)}>
     48 + GradeJS is currently processing this website. <br />
     49 + It may take a few minutes and depends on the number of JavaScript files and their size.
     50 + </div>
     51 + ) : (
     52 + webpages.length > 0 && (
    55 53   <div className={styles.disclaimer}>
    56 54   The <strong>beta</strong> version of GradeJS is able to detect only 1,826 popular
    57 55   packages with up to 85% accuracy.
    skipped 17 lines
    75 73   </a>
    76 74   .
    77 75   </div>
    78  - ))}
     76 + )
     77 + )}
    79 78   
    80 79   <div className={styles.disclaimer}>
    81 80   Packages that are known to be vulnerable are now highlighted with{' '}
    skipped 65 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/pages/Home.tsx
     1 +import React, { useCallback, useEffect } from 'react';
     2 +import { Error, Home } from 'components/layouts';
     3 +import { trackCustomEvent } from '../../services/analytics';
     4 +import {
     5 + useAppDispatch,
     6 + parseWebsite,
     7 + resetError,
     8 + useAppSelector,
     9 + homeDefaultSelector,
     10 +} from '../../store';
     11 +import { useNavigate } from 'react-router-dom';
     12 + 
     13 +export function HomePage() {
     14 + const navigate = useNavigate();
     15 + const dispatch = useAppDispatch();
     16 + const state = useAppSelector(homeDefaultSelector);
     17 + 
     18 + const handleDetectStart = useCallback(async (data: { address: string }) => {
     19 + trackCustomEvent('HomePage', 'WebsiteSubmitted');
     20 + await dispatch(parseWebsite(data.address));
     21 + }, []);
     22 + 
     23 + if (state.isFailed) {
     24 + return (
     25 + <Error
     26 + host={state.hostname}
     27 + onReportClick={() => {
     28 + trackCustomEvent('HomePage', 'ClickReport');
     29 + }}
     30 + onRetryClick={() => {
     31 + trackCustomEvent('HomePage', 'ClickRetry');
     32 + dispatch(resetError());
     33 + }}
     34 + />
     35 + );
     36 + }
     37 + 
     38 + // TODO: properly handle history/routing
     39 + useEffect(() => {
     40 + if (!state.isLoading && !state.isFailed && state.hostname) {
     41 + navigate(`/w/${state.hostname}`, { replace: true });
     42 + }
     43 + });
     44 + 
     45 + return <Home onSubmit={handleDetectStart} isLoading={state.isLoading} />;
     46 +}
     47 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/pages/HomePage.tsx
    1  -import React, { useCallback, useState } from 'react';
    2  -import { Navigate } from 'react-router-dom';
    3  -import { Error, Home } from 'components/layouts';
    4  -import { trackCustomEvent } from '../../services/analytics';
    5  - 
    6  -const baseUrl = process.env.API_ORIGIN;
    7  - 
    8  -export default function HomePage() {
    9  - const [isFailed, setFailed] = useState('');
    10  - const [isLoading, setLoading] = useState(false);
    11  - const [hostname, setHostname] = useState('');
    12  - 
    13  - const handleDetectStart = useCallback((data: { address: string }) => {
    14  - setLoading(true);
    15  - trackCustomEvent('HomePage', 'WebsiteSubmitted');
    16  - 
    17  - const host = new URL(data.address).hostname;
    18  - 
    19  - fetch(`${baseUrl}/webpage`, {
    20  - method: 'POST',
    21  - body: JSON.stringify({ url: data.address }),
    22  - headers: { 'Content-Type': 'application/json' },
    23  - })
    24  - .then((response) => response.json())
    25  - .then(() => {
    26  - setLoading(false);
    27  - setHostname(host);
    28  - })
    29  - .catch(() => {
    30  - setFailed(host);
    31  - setLoading(false);
    32  - });
    33  - }, []);
    34  - 
    35  - if (hostname) {
    36  - return <Navigate replace to={`/w/${hostname}`} />;
    37  - }
    38  - 
    39  - if (isFailed) {
    40  - return (
    41  - <Error
    42  - host={isFailed}
    43  - onReportClick={() => {
    44  - trackCustomEvent('HomePage', 'ClickReport');
    45  - }}
    46  - onRetryClick={() => {
    47  - trackCustomEvent('HomePage', 'ClickRetry');
    48  - setFailed('');
    49  - }}
    50  - />
    51  - );
    52  - }
    53  - 
    54  - return <Home onSubmit={handleDetectStart} isLoading={isLoading} />;
    55  -}
    56  - 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/pages/WebsiteHostnamePage.tsx
    1  -import React, { useCallback, useEffect, useMemo, useState } from 'react';
    2  -import semver from 'semver';
    3  -import { useParams, Navigate } from 'react-router-dom';
    4  -import { Error as ErrorLayout, Website } from 'components/layouts';
    5  -import { DefaultFiltersAndSorters } from '../layouts/Filters/Filters';
    6  -import { DetectedPackageData } from '../ui/Package/Package';
    7  -import { PackageVulnerabilityData, SeverityWeightMap } from '../ui/Vulnerability/Vulnerability';
    8  -import { trackCustomEvent } from '../../services/analytics';
    9  - 
    10  -const baseUrl = process.env.API_ORIGIN;
    11  - 
    12  -async function fetchApi(hostname: string) {
    13  - return fetch(`${baseUrl}/website/${hostname}`).then((response) => {
    14  - if (response.status !== 200) {
    15  - throw new Error();
    16  - } else {
    17  - return response.json();
    18  - }
    19  - });
    20  -}
    21  - 
    22  -type DetectionResult = {
    23  - packages: DetectedPackageData[];
    24  - vulnerabilities: Record<string, PackageVulnerabilityData[]>;
    25  - webpages: Array<{ status: string }>;
    26  -};
    27  - 
    28  -const compareByPopularity = (left: DetectedPackageData, right: DetectedPackageData) =>
    29  - (right.registryMetadata?.monthlyDownloads ?? 0) - (left.registryMetadata?.monthlyDownloads ?? 0);
    30  - 
    31  -let fetchStartTime: number | null = null;
    32  - 
    33  -export default function WebsiteHostnamePage() {
    34  - const { hostname } = useParams();
    35  - const [detectionResult, setDetectionResult] = useState<DetectionResult>({
    36  - packages: [],
    37  - vulnerabilities: {},
    38  - webpages: [],
    39  - });
    40  - const [isError, setError] = useState(false);
    41  - const [filters, setFilters] = useState(DefaultFiltersAndSorters);
    42  - 
    43  - const { webpages, vulnerabilities, packages } = detectionResult;
    44  - 
    45  - const isProtected = useMemo(
    46  - () => webpages.find((item: { status: string }) => item.status === 'protected'),
    47  - [webpages]
    48  - );
    49  - 
    50  - const pickHighestSeverity = useCallback(
    51  - (packageName: string) => {
    52  - const packageVulnerabilities = vulnerabilities[packageName] ?? [];
    53  - 
    54  - return packageVulnerabilities
    55  - .map((it) => it.severity)
    56  - .filter((it): it is string => !!it)
    57  - .reduce(
    58  - (acc, val) => (SeverityWeightMap[acc] > SeverityWeightMap[val] ? acc : val),
    59  - 'UNKNOWN'
    60  - );
    61  - },
    62  - [vulnerabilities]
    63  - );
    64  - 
    65  - const packagesFiltered = useMemo(() => {
    66  - let packagesShallowCopy = [...packages];
    67  - switch (filters.sort) {
    68  - case 'confidenceScore':
    69  - // TODO
    70  - break;
    71  - case 'importDepth':
    72  - // TODO
    73  - break;
    74  - case 'severity':
    75  - packagesShallowCopy = packagesShallowCopy.sort((left, right) => {
    76  - const leftSeverity = pickHighestSeverity(left.packageName);
    77  - const rightSeverity = pickHighestSeverity(right.packageName);
    78  - 
    79  - if (leftSeverity !== rightSeverity) {
    80  - return SeverityWeightMap[rightSeverity] - SeverityWeightMap[leftSeverity];
    81  - }
    82  - 
    83  - return compareByPopularity(left, right);
    84  - });
    85  - break;
    86  - case 'size':
    87  - packagesShallowCopy = packagesShallowCopy.sort(
    88  - (left, right) =>
    89  - (right.packageMetadata?.approximateByteSize ?? 0) -
    90  - (left.packageMetadata?.approximateByteSize ?? 0)
    91  - );
    92  - break;
    93  - case 'name':
    94  - packagesShallowCopy = packagesShallowCopy.sort((left, right) =>
    95  - left.packageName.localeCompare(right.packageName)
    96  - );
    97  - break;
    98  - case 'packagePopularity':
    99  - default:
    100  - packagesShallowCopy = packagesShallowCopy.sort(compareByPopularity);
    101  - }
    102  - 
    103  - switch (filters.filter) {
    104  - case 'name':
    105  - if (filters.filterPackageName) {
    106  - packagesShallowCopy = packagesShallowCopy.filter((pkg) =>
    107  - pkg.packageName.includes(filters.filterPackageName ?? '')
    108  - );
    109  - }
    110  - break;
    111  - 
    112  - case 'outdated':
    113  - packagesShallowCopy = packagesShallowCopy.filter(
    114  - (pkg) =>
    115  - pkg.registryMetadata &&
    116  - semver.gtr(pkg.registryMetadata.latestVersion, pkg.packageVersionRange)
    117  - );
    118  - break;
    119  - case 'vulnerable':
    120  - packagesShallowCopy = packagesShallowCopy.filter(
    121  - (pkg) => !!vulnerabilities[pkg.packageName]
    122  - );
    123  - break;
    124  - case 'all':
    125  - default:
    126  - break;
    127  - }
    128  - 
    129  - return packagesShallowCopy;
    130  - }, [vulnerabilities, packages, filters]);
    131  - 
    132  - const isInvalidResult =
    133  - packages.length === 0 &&
    134  - webpages.length > 0 &&
    135  - !webpages.find((item) => item.status === 'pending');
    136  - 
    137  - useEffect(() => {
    138  - if (hostname) {
    139  - fetchApi(hostname)
    140  - .then((response) => {
    141  - setDetectionResult({
    142  - vulnerabilities: {},
    143  - ...response.data,
    144  - });
    145  - })
    146  - .catch(() => {
    147  - setError(true);
    148  - });
    149  - }
    150  - }, []);
    151  - 
    152  - useEffect(() => {
    153  - const hasPendingPages = !!webpages.find((item) => item.status === 'pending');
    154  - 
    155  - if (hasPendingPages && hostname) {
    156  - if (fetchStartTime === null) {
    157  - fetchStartTime = Date.now();
    158  - }
    159  - const timeoutId = setTimeout(() => {
    160  - fetchApi(hostname)
    161  - .then((response) => {
    162  - setDetectionResult({
    163  - vulnerabilities: {},
    164  - ...response.data,
    165  - });
    166  - })
    167  - .catch(() => {
    168  - setError(true);
    169  - });
    170  - }, 5000);
    171  - 
    172  - return () => clearTimeout(timeoutId);
    173  - }
    174  - 
    175  - return () => {};
    176  - }, [webpages]);
    177  - 
    178  - if (!hostname || isError) {
    179  - return <Navigate replace to='/' />;
    180  - }
    181  - 
    182  - if (isProtected) {
    183  - trackCustomEvent('HostnamePage', 'SiteProtected');
    184  - return (
    185  - <ErrorLayout
    186  - message='The entered website appears to be protected by a third-party service, such as DDoS prevention, password protection or geolocation restrictions.'
    187  - action='Would you like to try another URL or report an issue?'
    188  - actionTitle='Try another URL'
    189  - host={hostname}
    190  - onRetryClick={() => {
    191  - trackCustomEvent('HostnamePage', 'ClickRetry_Protected');
    192  - document.location = '/';
    193  - }}
    194  - onReportClick={() => {
    195  - trackCustomEvent('HostnamePage', 'ClickReport_Protected');
    196  - }}
    197  - />
    198  - );
    199  - }
    200  - 
    201  - if (isInvalidResult) {
    202  - trackCustomEvent('HostnamePage', 'SiteInvalid');
    203  - return (
    204  - <ErrorLayout
    205  - message='It looks like the entered website is not built with Webpack.'
    206  - action='Would you like to try another URL or report an issue?'
    207  - actionTitle='Try another URL'
    208  - host={hostname}
    209  - onRetryClick={() => {
    210  - trackCustomEvent('HostnamePage', 'ClickRetry_Invalid');
    211  - document.location = '/';
    212  - }}
    213  - onReportClick={() => {
    214  - trackCustomEvent('HostnamePage', 'ClickReport_Invalid');
    215  - }}
    216  - />
    217  - );
    218  - }
    219  - 
    220  - const isPending = webpages.length === 0 || webpages.find((item) => item.status === 'pending');
    221  - 
    222  - if (!isPending && fetchStartTime !== null) {
    223  - trackCustomEvent('HostnamePage', 'WebsiteLoaded', {
    224  - value: Date.now() - fetchStartTime,
    225  - });
    226  - fetchStartTime = null;
    227  - }
    228  - 
    229  - return (
    230  - <Website
    231  - webpages={webpages}
    232  - packages={packagesFiltered}
    233  - host={hostname}
    234  - vulnerabilities={vulnerabilities}
    235  - onFiltersApply={setFilters}
    236  - />
    237  - );
    238  -}
    239  - 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/pages/WebsiteResults.tsx
     1 +import React, { useEffect } from 'react';
     2 +import { useParams, useNavigate } from 'react-router-dom';
     3 +import { Error as ErrorLayout, Website } from 'components/layouts';
     4 +import { trackCustomEvent } from '../../services/analytics';
     5 +import {
     6 + useAppDispatch,
     7 + useAppSelector,
     8 + applyFilters,
     9 + getWebsite,
     10 + websiteResultsSelectors as selectors,
     11 +} from '../../store';
     12 +import { FiltersState } from '../layouts/Filters/Filters';
     13 + 
     14 +export function WebsiteResultsPage() {
     15 + const { hostname } = useParams();
     16 + const navigate = useNavigate();
     17 + const dispatch = useAppDispatch();
     18 + const { webpages, vulnerabilities } = useAppSelector(selectors.default);
     19 + const packagesFiltered = useAppSelector(selectors.packagesSortedAndFiltered);
     20 + const { isProtected, isPending, isLoading, isFailed, isInvalid } = useAppSelector(
     21 + selectors.stateFlags
     22 + );
     23 + const setFilters = (filters: FiltersState) => dispatch(applyFilters(filters));
     24 + 
     25 + useEffect(() => {
     26 + if (hostname && !isLoading && isPending) {
     27 + const promise = dispatch(getWebsite(hostname));
     28 + return function cleanup() {
     29 + promise.abort();
     30 + };
     31 + }
     32 + return () => {};
     33 + }, [hostname]);
     34 + 
     35 + // TODO: properly handle history/routing
     36 + useEffect(() => {
     37 + if (!hostname || isFailed) {
     38 + navigate('/', { replace: true });
     39 + }
     40 + }, [hostname]);
     41 + 
     42 + if (isProtected) {
     43 + // TODO: move to tracking middleware?
     44 + trackCustomEvent('HostnamePage', 'SiteProtected');
     45 + return (
     46 + <ErrorLayout
     47 + message='The entered website appears to be protected by a third-party service, such as DDoS prevention, password protection or geolocation restrictions.'
     48 + action='Would you like to try another URL or report an issue?'
     49 + actionTitle='Try another URL'
     50 + host={hostname ?? ''}
     51 + onRetryClick={() => {
     52 + trackCustomEvent('HostnamePage', 'ClickRetry_Protected');
     53 + navigate('/', { replace: false });
     54 + }}
     55 + onReportClick={() => {
     56 + trackCustomEvent('HostnamePage', 'ClickReport_Protected');
     57 + }}
     58 + />
     59 + );
     60 + }
     61 + 
     62 + if (isInvalid) {
     63 + // TODO: move to tracking middleware?
     64 + trackCustomEvent('HostnamePage', 'SiteInvalid');
     65 + return (
     66 + <ErrorLayout
     67 + message='It looks like the entered website is not built with Webpack.'
     68 + action='Would you like to try another URL or report an issue?'
     69 + actionTitle='Try another URL'
     70 + host={hostname ?? ''}
     71 + onRetryClick={() => {
     72 + trackCustomEvent('HostnamePage', 'ClickRetry_Invalid');
     73 + navigate('/', { replace: false });
     74 + }}
     75 + onReportClick={() => {
     76 + trackCustomEvent('HostnamePage', 'ClickReport_Invalid');
     77 + }}
     78 + />
     79 + );
     80 + }
     81 + return (
     82 + <Website
     83 + isLoading={isLoading}
     84 + isPending={isPending}
     85 + webpages={webpages}
     86 + packages={packagesFiltered}
     87 + host={hostname ?? ''}
     88 + vulnerabilities={vulnerabilities}
     89 + onFiltersApply={setFilters}
     90 + />
     91 + );
     92 +}
     93 + 
  • ■ ■ ■ ■ ■
    packages/web/src/components/ui/Package/Package.tsx
    skipped 4 lines
    5 5  import semver from 'semver';
    6 6  import styles from './Package.module.scss';
    7 7  import Dropdown from '../Dropdown/Dropdown';
    8  -import Vulnerability, { PackageVulnerabilityData } from '../Vulnerability/Vulnerability';
     8 +import Vulnerability from '../Vulnerability/Vulnerability';
    9 9  import TagBadge from '../TagBadge/TagBadge';
    10 10  import { trackCustomEvent } from '../../../services/analytics';
    11  - 
    12  -export type DetectedPackageData = {
    13  - packageName: string;
    14  - possiblePackageVersions: string[];
    15  - packageVersionRange: string;
    16  - packageMetadata?: {
    17  - approximateByteSize: number | null;
    18  - };
    19  - registryMetadata?: {
    20  - latestVersion: string;
    21  - description?: string;
    22  - repositoryUrl?: string;
    23  - homepageUrl?: string;
    24  - monthlyDownloads?: number;
    25  - };
    26  -};
     11 +import { Api } from '../../../services/apiClient';
    27 12   
    28 13  export type Props = {
    29 14   className?: string;
    30 15   variant?: 'grid' | 'lines';
    31  - pkg: DetectedPackageData;
    32  - vulnerabilities: PackageVulnerabilityData[];
     16 + pkg: Api.WebPagePackage;
     17 + vulnerabilities: Api.Vulnerability[];
    33 18  };
    34 19   
    35 20  export default function Package({ className, variant = 'grid', pkg, vulnerabilities }: Props) {
    skipped 106 lines
  • ■ ■ ■ ■ ■
    packages/web/src/components/ui/Vulnerability/Vulnerability.tsx
    skipped 2 lines
    3 3  import TagBadge from '../TagBadge/TagBadge';
    4 4  import styles from './Vulnerability.module.scss';
    5 5  import { External } from '../../icons';
    6  - 
    7  -export type PackageVulnerabilityData = {
    8  - affectedPackageName: string;
    9  - affectedVersionRange: string;
    10  - osvId: string;
    11  - detailsUrl: string;
    12  - summary?: string;
    13  - severity?: string;
    14  -};
     6 +import { Api } from '../../../services/apiClient';
    15 7   
    16 8  export type Props = {
    17  - vulnerability: PackageVulnerabilityData;
     9 + vulnerability: Api.Vulnerability;
    18 10  };
    19 11   
    20 12  export default function Vulnerability({ vulnerability }: Props) {
    skipped 55 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/index.tsx
    1 1  import React from 'react';
    2 2  import ReactDOM from 'react-dom';
     3 +import { Provider } from 'react-redux';
    3 4  import { BrowserRouter } from 'react-router-dom';
    4  -import App from 'components/App';
     5 +import { store } from './store';
     6 +import { App } from './components/App';
    5 7  import 'styles/global.scss';
    6 8   
    7 9  ReactDOM.render(
    8  - <BrowserRouter>
    9  - <App />
    10  - </BrowserRouter>,
     10 + <Provider store={store}>
     11 + <BrowserRouter>
     12 + <App />
     13 + </BrowserRouter>
     14 + </Provider>,
    11 15   document.getElementById('app')
    12 16  );
    13 17   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/services/apiClient.ts
     1 +import { createTRPCClient } from '@trpc/client';
     2 +import type { inferProcedureOutput } from '@trpc/server';
     3 +import type { AppRouter, Api } from '../../../public-api/src/router';
     4 + 
     5 +// Helper types
     6 +export type TQuery = keyof AppRouter['_def']['queries'];
     7 +export type TMutation = keyof AppRouter['_def']['mutations'];
     8 +export type InferMutationOutput<TRouteKey extends TMutation> = inferProcedureOutput<
     9 + AppRouter['_def']['mutations'][TRouteKey]
     10 +>;
     11 +export type InferQueryOutput<TRouteKey extends TQuery> = inferProcedureOutput<
     12 + AppRouter['_def']['queries'][TRouteKey]
     13 +>;
     14 + 
     15 +if (!process.env.API_ORIGIN) {
     16 + throw new Error('API_ORIGIN must be defined');
     17 +}
     18 + 
     19 +export const client = createTRPCClient<AppRouter>({
     20 + url: process.env.API_ORIGIN,
     21 +});
     22 + 
     23 +export type HealthcheckOutput = InferQueryOutput<'healthcheck'>;
     24 +export type SyncWebsiteOutput = InferMutationOutput<'syncWebsite'>;
     25 +export type RequestParseWebsiteOutput = InferMutationOutput<'requestParseWebsite'>;
     26 +export type { Api };
     27 +export type ApiClient = typeof client;
     28 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/index.ts
     1 +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
     2 +import { configureStore } from '@reduxjs/toolkit';
     3 +import { homeReducer } from './slices/home';
     4 +import { websiteResultsReducer } from './slices/websiteResults';
     5 + 
     6 +export const store = configureStore({
     7 + reducer: {
     8 + home: homeReducer,
     9 + webpageResults: websiteResultsReducer,
     10 + },
     11 + preloadedState: {},
     12 +});
     13 + 
     14 +export type RootState = ReturnType<typeof store.getState>;
     15 +export type AppDispatch = typeof store.dispatch;
     16 +export const useAppDispatch = () => useDispatch<typeof store.dispatch>();
     17 +export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
     18 + 
     19 +export { parseWebsite, resetError, defaultSelector as homeDefaultSelector } from './slices/home';
     20 +export { applyFilters, getWebsite } from './slices/websiteResults';
     21 +export { selectors as websiteResultsSelectors } from './selectors/websiteResults';
     22 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/selectors/websiteResults.ts
     1 +import semver from 'semver';
     2 +import memoize from 'lodash.memoize';
     3 +import { createSelector } from '@reduxjs/toolkit';
     4 +import { GithubAdvisorySeverity } from '@gradejs-public/shared';
     5 +import { RootState } from '../';
     6 +import { FiltersState } from '../../components/layouts/Filters/Filters';
     7 +import { SeverityWeightMap } from '../../components/ui/Vulnerability/Vulnerability';
     8 +import type { Api } from '../../services/apiClient';
     9 + 
     10 +const getFlags = (state: RootState) => ({
     11 + isLoading: state.webpageResults.isLoading,
     12 + isFailed: state.webpageResults.isFailed,
     13 +});
     14 +const getPackages = (state: RootState) => state.webpageResults.detectionResult.packages;
     15 +const getWebpages = (state: RootState) => state.webpageResults.detectionResult.webpages;
     16 +const getVulnerabilities = (state: RootState) =>
     17 + state.webpageResults.detectionResult.vulnerabilities;
     18 +const getSorting = (state: RootState) => state.webpageResults.filters.sort;
     19 +const getFilter = (state: RootState) => state.webpageResults.filters.filter;
     20 +const getPackageNameFilter = (state: RootState) => state.webpageResults.filters.filterPackageName;
     21 + 
     22 +const compareByPopularity = (left: Api.WebPagePackage, right: Api.WebPagePackage) =>
     23 + (right.registryMetadata?.monthlyDownloads ?? 0) - (left.registryMetadata?.monthlyDownloads ?? 0);
     24 + 
     25 +const pickHighestSeverity = memoize(
     26 + (packageName: string, vulnerabilities: Record<string, Api.Vulnerability[]>) =>
     27 + (vulnerabilities[packageName] ?? [])
     28 + .map((it) => it.severity)
     29 + .filter((it): it is GithubAdvisorySeverity => !!it)
     30 + .reduce(
     31 + (acc, val) => (val && SeverityWeightMap[acc] > SeverityWeightMap[val] ? acc : val),
     32 + 'UNKNOWN'
     33 + )
     34 +);
     35 + 
     36 +const sortingModes: Record<
     37 + FiltersState['sort'],
     38 + (
     39 + packages: Api.WebPagePackage[],
     40 + vulnerabilities: Record<string, Api.Vulnerability[]>
     41 + ) => Api.WebPagePackage[]
     42 +> = {
     43 + // TODO
     44 + confidenceScore: (packages) => packages,
     45 + // TODO
     46 + importDepth: (packages) => packages,
     47 + severity: (packages, vulnerabilities) =>
     48 + [...packages].sort((left, right) => {
     49 + const leftSeverity = pickHighestSeverity(left.packageName, vulnerabilities);
     50 + const rightSeverity = pickHighestSeverity(right.packageName, vulnerabilities);
     51 + 
     52 + if (leftSeverity !== rightSeverity) {
     53 + return SeverityWeightMap[rightSeverity] - SeverityWeightMap[leftSeverity];
     54 + }
     55 + 
     56 + return compareByPopularity(left, right);
     57 + }),
     58 + size: (packages) =>
     59 + [...packages].sort(
     60 + (left, right) =>
     61 + (right.packageMetadata?.approximateByteSize ?? 0) -
     62 + (left.packageMetadata?.approximateByteSize ?? 0)
     63 + ),
     64 + name: (packages) =>
     65 + [...packages].sort((left, right) => left.packageName.localeCompare(right.packageName)),
     66 + packagePopularity: (packages) => [...packages].sort(compareByPopularity),
     67 +};
     68 + 
     69 +const filterModes: Record<
     70 + FiltersState['filter'],
     71 + (
     72 + packages: Api.WebPagePackage[],
     73 + vulnerabilities: Record<string, Api.Vulnerability[]>,
     74 + packageName?: string
     75 + ) => Api.WebPagePackage[]
     76 +> = {
     77 + name: (packages, vulnerabilities, packageName) => {
     78 + if (!packageName) {
     79 + return packages;
     80 + }
     81 + return packages.filter((pkg) => pkg.packageName.includes(packageName));
     82 + },
     83 + outdated: (packages) =>
     84 + packages.filter(
     85 + (pkg) =>
     86 + pkg.registryMetadata &&
     87 + semver.gtr(pkg.registryMetadata.latestVersion, pkg.packageVersionRange)
     88 + ),
     89 + vulnerable: (packages, vulnerabilities) =>
     90 + packages.filter((pkg) => !!vulnerabilities[pkg.packageName]),
     91 + all: (packages) => packages,
     92 +};
     93 + 
     94 +export const selectors = {
     95 + default: createSelector([getWebpages, getVulnerabilities], (webpages, vulnerabilities) => ({
     96 + webpages,
     97 + vulnerabilities,
     98 + })),
     99 + stateFlags: createSelector([getWebpages, getPackages, getFlags], (webpages, packages, flags) => ({
     100 + ...flags,
     101 + isInvalid:
     102 + packages.length === 0 &&
     103 + webpages.length > 0 &&
     104 + webpages.some((item) => item.status === 'pending'),
     105 + isPending: webpages.length === 0 || webpages.some((item) => item.status === 'pending'),
     106 + isProtected: webpages.some((item) => item.status === 'protected'),
     107 + })),
     108 + packagesSortedAndFiltered: createSelector(
     109 + [getPackages, getVulnerabilities, getSorting, getFilter, getPackageNameFilter],
     110 + (packages, vulnerabilities, sorting, filter, packageNameFilter) =>
     111 + filterModes[filter](
     112 + sortingModes[sorting](packages, vulnerabilities),
     113 + vulnerabilities,
     114 + packageNameFilter
     115 + )
     116 + ),
     117 +};
     118 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/slices/home.ts
     1 +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
     2 +import { client } from '../../services/apiClient';
     3 +import { RootState } from '../';
     4 + 
     5 +const parseWebsite = createAsyncThunk('home/submitWebsite', async (url: string) => {
     6 + await client.mutation('requestParseWebsite', url);
     7 +});
     8 + 
     9 +const home = createSlice({
     10 + name: 'home',
     11 + initialState: {
     12 + isLoading: false,
     13 + isFailed: false,
     14 + hostname: '',
     15 + },
     16 + reducers: {
     17 + resetError(state) {
     18 + state.hostname = '';
     19 + state.isFailed = false;
     20 + },
     21 + },
     22 + extraReducers(builder) {
     23 + builder
     24 + .addCase(parseWebsite.pending, (state, action) => {
     25 + state.hostname = new URL(action.meta.arg).hostname;
     26 + state.isLoading = true;
     27 + state.isFailed = false;
     28 + })
     29 + .addCase(parseWebsite.fulfilled, (state) => {
     30 + state.isLoading = false;
     31 + })
     32 + .addCase(parseWebsite.rejected, (state) => {
     33 + state.isFailed = true;
     34 + state.isLoading = false;
     35 + });
     36 + },
     37 +});
     38 + 
     39 +export const { resetError } = home.actions;
     40 +export const defaultSelector = (state: RootState) => state.home;
     41 +export const homeReducer = home.reducer;
     42 +export { parseWebsite };
     43 + 
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/slices/websiteResults.ts
     1 +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
     2 +import { DefaultFiltersAndSorters, FiltersState } from '../../components/layouts/Filters/Filters';
     3 +import { client, SyncWebsiteOutput } from '../../services/apiClient';
     4 +import { trackCustomEvent } from '../../services/analytics';
     5 + 
     6 +const defaultDetectionResult: SyncWebsiteOutput = {
     7 + packages: [],
     8 + vulnerabilities: {},
     9 + webpages: [],
     10 +};
     11 + 
     12 +const initialState = {
     13 + filters: { ...DefaultFiltersAndSorters },
     14 + isFailed: false,
     15 + isLoading: false,
     16 + detectionResult: defaultDetectionResult,
     17 +};
     18 + 
     19 +const sleep = (ms: number | undefined) =>
     20 + new Promise((r) => {
     21 + setTimeout(r, ms);
     22 + });
     23 + 
     24 +const hasPendingPages = (result: DetectionResult) =>
     25 + !!result.webpages.find((item) => item.status === 'pending');
     26 + 
     27 +const getWebsite = createAsyncThunk('websiteResults/getWebsite', async (hostname: string) => {
     28 + const loadStartTime = Date.now();
     29 + let results = await client.mutation('syncWebsite', hostname);
     30 + while (hasPendingPages(results)) {
     31 + await sleep(5000);
     32 + results = await client.mutation('syncWebsite', hostname);
     33 + }
     34 + // TODO: move to tracking middleware?
     35 + trackCustomEvent('HostnamePage', 'WebsiteLoaded', {
     36 + value: Date.now() - loadStartTime,
     37 + });
     38 + return results;
     39 +});
     40 + 
     41 +const websiteResults = createSlice({
     42 + name: 'websiteResults',
     43 + initialState,
     44 + reducers: {
     45 + resetFilters(state) {
     46 + state.filters = { ...DefaultFiltersAndSorters };
     47 + },
     48 + applyFilters(state, action: PayloadAction<FiltersState>) {
     49 + state.filters = action.payload;
     50 + },
     51 + },
     52 + extraReducers(builder) {
     53 + builder
     54 + .addCase(getWebsite.pending, (state) => {
     55 + state.isLoading = true;
     56 + state.isFailed = false;
     57 + })
     58 + .addCase(getWebsite.fulfilled, (state, action) => {
     59 + state.isLoading = false;
     60 + state.detectionResult = action.payload;
     61 + })
     62 + .addCase(getWebsite.rejected, (state) => {
     63 + state.isFailed = true;
     64 + state.isLoading = false;
     65 + });
     66 + },
     67 +});
     68 + 
     69 +export type DetectionResult = typeof defaultDetectionResult;
     70 +export const { resetFilters, applyFilters } = websiteResults.actions;
     71 +export { getWebsite };
     72 +export const websiteResultsReducer = websiteResults.reducer;
     73 + 
  • ■ ■ ■ ■ ■ ■
    yarn.lock
    skipped 1718 lines
    1719 1719   dependencies:
    1720 1720   regenerator-runtime "^0.13.4"
    1721 1721   
     1722 +"@babel/runtime@^7.12.1", "@babel/runtime@^7.9.0", "@babel/runtime@^7.9.2":
     1723 + version "7.18.9"
     1724 + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a"
     1725 + integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==
     1726 + dependencies:
     1727 + regenerator-runtime "^0.13.4"
     1728 + 
    1722 1729  "@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.3.3":
    1723 1730   version "7.16.7"
    1724 1731   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155"
    skipped 629 lines
    2354 2361   version "2.11.4"
    2355 2362   resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.4.tgz#d8c7b8db9226d2d7664553a0741ad7d0397ee503"
    2356 2363   integrity sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==
     2364 + 
     2365 +"@reduxjs/toolkit@^1.8.3":
     2366 + version "1.8.3"
     2367 + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.8.3.tgz#9c6a9c497bde43a67618d37a4175a00ae12efeb2"
     2368 + integrity sha512-lU/LDIfORmjBbyDLaqFN2JB9YmAT1BElET9y0ZszwhSBa5Ef3t6o5CrHupw5J1iOXwd+o92QfQZ8OJpwXvsssg==
     2369 + dependencies:
     2370 + immer "^9.0.7"
     2371 + redux "^4.1.2"
     2372 + redux-thunk "^2.4.1"
     2373 + reselect "^4.1.5"
    2357 2374   
    2358 2375  "@sideway/address@^4.1.3":
    2359 2376   version "4.1.4"
    skipped 1144 lines
    3504 3521   resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
    3505 3522   integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
    3506 3523   
     3524 +"@trpc/client@^9.27.0":
     3525 + version "9.27.0"
     3526 + resolved "https://registry.yarnpkg.com/@trpc/client/-/client-9.27.0.tgz#6fb2af2df051dea834173e5b9f12e74c65662af2"
     3527 + integrity sha512-Yh+oDBiANArjDx69bSnXqIK36wjlRm8iGLQkMmrvTTFwVRt3hgfreCzjyj4PgNEd4SNX4iGrhLLPIDHeH+qlJA==
     3528 + dependencies:
     3529 + "@babel/runtime" "^7.9.0"
     3530 + 
     3531 +"@trpc/server@^9.26.2":
     3532 + version "9.26.2"
     3533 + resolved "https://registry.yarnpkg.com/@trpc/server/-/server-9.26.2.tgz#2386d0d0c76b9d6cbdcef3006ea3221729617c21"
     3534 + integrity sha512-J1kP072cVYC5H2qFhiLQ/gV57s0of++9QLLKf9n8mVfx300tecKQW2eu/oFvRCDPRTTQ1prHz9zm15IzLwoRrw==
     3535 + 
    3507 3536  "@tsconfig/node10@^1.0.7":
    3508 3537   version "1.0.8"
    3509 3538   resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9"
    skipped 168 lines
    3678 3707   dependencies:
    3679 3708   "@types/unist" "*"
    3680 3709   
     3710 +"@types/hoist-non-react-statics@^3.3.1":
     3711 + version "3.3.1"
     3712 + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
     3713 + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
     3714 + dependencies:
     3715 + "@types/react" "*"
     3716 + hoist-non-react-statics "^3.3.0"
     3717 + 
    3681 3718  "@types/html-minifier-terser@^5.0.0":
    3682 3719   version "5.1.2"
    3683 3720   resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-5.1.2.tgz#693b316ad323ea97eed6b38ed1a3cc02b1672b57"
    skipped 57 lines
    3741 3778   version "0.2.0"
    3742 3779   resolved "https://registry.yarnpkg.com/@types/jsonpath/-/jsonpath-0.2.0.tgz#13c62db22a34d9c411364fac79fd374d63445aa1"
    3743 3780   integrity sha512-v7qlPA0VpKUlEdhghbDqRoKMxFB3h3Ch688TApBJ6v+XLDdvWCGLJIYiPKGZnS6MAOie+IorCfNYVHOPIHSWwQ==
     3781 + 
     3782 +"@types/lodash.memoize@^4.1.7":
     3783 + version "4.1.7"
     3784 + resolved "https://registry.yarnpkg.com/@types/lodash.memoize/-/lodash.memoize-4.1.7.tgz#aff94ab32813c557cbc1104e127030e3d60a3b27"
     3785 + integrity sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==
     3786 + dependencies:
     3787 + "@types/lodash" "*"
     3788 + 
     3789 +"@types/lodash@*":
     3790 + version "4.14.182"
     3791 + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
     3792 + integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
    3744 3793   
    3745 3794  "@types/mdast@^3.0.0":
    3746 3795   version "3.0.10"
    skipped 218 lines
    3965 4014   version "2.0.6"
    3966 4015   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
    3967 4016   integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
     4017 + 
     4018 +"@types/use-sync-external-store@^0.0.3":
     4019 + version "0.0.3"
     4020 + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43"
     4021 + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
    3968 4022   
    3969 4023  "@types/webpack-env@^1.16.0":
    3970 4024   version "1.16.3"
    skipped 4465 lines
    8436 8490   minimalistic-assert "^1.0.0"
    8437 8491   minimalistic-crypto-utils "^1.0.1"
    8438 8492   
    8439  -hoist-non-react-statics@^3.3.0:
     8493 +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
    8440 8494   version "3.3.2"
    8441 8495   resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
    8442 8496   integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
    skipped 274 lines
    8717 8771   version "5.2.0"
    8718 8772   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
    8719 8773   integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
     8774 + 
     8775 +immer@^9.0.7:
     8776 + version "9.0.15"
     8777 + resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.15.tgz#0b9169e5b1d22137aba7d43f8a81a495dd1b62dc"
     8778 + integrity sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ==
    8720 8779   
    8721 8780  import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
    8722 8781   version "3.3.0"
    skipped 1439 lines
    10162 10221   resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
    10163 10222   integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
    10164 10223   
    10165  -[email protected]:
     10224 +[email protected], lodash.memoize@^4.1.2:
    10166 10225   version "4.1.2"
    10167 10226   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
    10168 10227   integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
    skipped 2051 lines
    12220 12279   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
    12221 12280   integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
    12222 12281   
     12282 +react-is@^18.0.0:
     12283 + version "18.2.0"
     12284 + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
     12285 + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
     12286 + 
    12223 12287  react-popper-tooltip@^3.1.1:
    12224 12288   version "3.1.1"
    12225 12289   resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-3.1.1.tgz#329569eb7b287008f04fcbddb6370452ad3f9eac"
    skipped 10 lines
    12236 12300   dependencies:
    12237 12301   react-fast-compare "^3.0.1"
    12238 12302   warning "^4.0.2"
     12303 + 
     12304 +react-redux@^8.0.2:
     12305 + version "8.0.2"
     12306 + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.0.2.tgz#bc2a304bb21e79c6808e3e47c50fe1caf62f7aad"
     12307 + integrity sha512-nBwiscMw3NoP59NFCXFf02f8xdo+vSHT/uZ1ldDwF7XaTpzm+Phk97VT4urYBl5TYAPNVaFm12UHAEyzkpNzRA==
     12308 + dependencies:
     12309 + "@babel/runtime" "^7.12.1"
     12310 + "@types/hoist-non-react-statics" "^3.3.1"
     12311 + "@types/use-sync-external-store" "^0.0.3"
     12312 + hoist-non-react-statics "^3.3.2"
     12313 + react-is "^18.0.0"
     12314 + use-sync-external-store "^1.0.0"
    12239 12315   
    12240 12316  react-refresh@^0.11.0:
    12241 12317   version "0.11.0"
    skipped 152 lines
    12394 12470   indent-string "^4.0.0"
    12395 12471   strip-indent "^3.0.0"
    12396 12472   
     12473 +redux-thunk@^2.4.1:
     12474 + version "2.4.1"
     12475 + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.1.tgz#0dd8042cf47868f4b29699941de03c9301a75714"
     12476 + integrity sha512-OOYGNY5Jy2TWvTL1KgAlVy6dcx3siPJ1wTq741EPyUKfn6W6nChdICjZwCd0p8AZBs5kWpZlbkXW2nE/zjUa+Q==
     12477 + 
     12478 +redux@^4.1.2:
     12479 + version "4.2.0"
     12480 + resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.0.tgz#46f10d6e29b6666df758780437651eeb2b969f13"
     12481 + integrity sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==
     12482 + dependencies:
     12483 + "@babel/runtime" "^7.9.2"
     12484 + 
    12397 12485  reflect-metadata@^0.1.13:
    12398 12486   version "0.1.13"
    12399 12487   resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08"
    skipped 241 lines
    12641 12729   version "1.0.0"
    12642 12730   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
    12643 12731   integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
     12732 + 
     12733 +reselect@^4.1.5:
     12734 + version "4.1.6"
     12735 + resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.6.tgz#19ca2d3d0b35373a74dc1c98692cdaffb6602656"
     12736 + integrity sha512-ZovIuXqto7elwnxyXbBtCPo9YFEr3uJqj2rRbcOOog1bmu2Ag85M4hixSwFWyaBMKXNgvPaJ9OSu9SkBPIeJHQ==
    12644 12737   
    12645 12738  resolve-cwd@^3.0.0:
    12646 12739   version "3.0.0"
    skipped 1833 lines
    14480 14573   dependencies:
    14481 14574   use-isomorphic-layout-effect "^1.0.0"
    14482 14575   
     14576 +use-sync-external-store@^1.0.0:
     14577 + version "1.2.0"
     14578 + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
     14579 + integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
     14580 + 
    14483 14581  use@^3.1.0:
    14484 14582   version "3.1.1"
    14485 14583   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
    skipped 702 lines
    15188 15286   version "0.8.15"
    15189 15287   resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
    15190 15288   integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==
     15289 + 
     15290 +zod@^3.17.10:
     15291 + version "3.17.10"
     15292 + resolved "https://registry.yarnpkg.com/zod/-/zod-3.17.10.tgz#8716a05e6869df6faaa878a44ffe3c79e615defb"
     15293 + integrity sha512-IHXnQYQuOOOL/XgHhgl8YjNxBHi3xX0mVcHmqsvJgcxKkEczPshoWdxqyFwsARpf41E0v9U95WUROqsHHxt0UQ==
    15191 15294   
    15192 15295  zwitch@^1.0.0:
    15193 15296   version "1.0.5"
    skipped 3 lines
Please wait...
Page is in error, reload to recover