├── .devcontainer └── devcontainer.json ├── .env.example ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── backend.dev.yml │ ├── core.dev.yml │ ├── cypress-e2e.dev.yml │ ├── cyress-core.dev.yml │ ├── publish.prod.yml │ ├── scanner-tests.dev.yml │ └── scanner.dev.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SECURITY.md ├── docker-compose.v1.yml ├── docker-compose.yml ├── files └── chrome.json ├── kong.yml ├── package.json ├── scripts ├── dev │ ├── create_admin_user.sh │ └── create_scanner_token.sh ├── run-scanner-test.sh ├── setup-e2e.sh └── setup-scanner-test-env.sh ├── src ├── backend │ ├── .gitignore │ ├── .releaserc │ ├── Dockerfile │ ├── docker-compose.yaml │ ├── migrations │ │ ├── 1683054191_created_api_tokens.js │ │ ├── 1683054191_created_scanners.js │ │ ├── 1683054191_created_scans.js │ │ ├── 1683054191_updated_users.js │ │ ├── 1683054193_create_scanner_user.js │ │ ├── 1704268370_updated_api_tokens.js │ │ ├── 1704278503_add_scans_options.js │ │ ├── 1704282038_add_scanners_name.js │ │ ├── 1704282039_add_name_default_scanner.js │ │ ├── 1708001745_updated_scans.js │ │ ├── 1708432935_added_scanstats.js │ │ ├── 1708514819_updated_scans_html_size.js │ │ ├── 1710102971_updated_scans_scandata.js │ │ ├── 1711023208_add_files_to_scans.js │ │ ├── 1712252550_generate_random_admin.js │ │ ├── 1713168223_mirror_scanner_ids.js │ │ ├── 1713175897_updated_scanners.js │ │ ├── 1713175898_updated_scanners_rule.js │ │ └── 1714641094_add_thumbnails_to_screenshots.js │ └── src │ │ ├── go.mod │ │ ├── go.sum │ │ ├── main.go │ │ └── webhood │ │ ├── adminapiroutes.go │ │ ├── apiroutes.go │ │ ├── apiv1.go │ │ ├── commands.go │ │ ├── config.go │ │ ├── go.mod │ │ ├── go.sum │ │ └── middleware.go ├── core │ ├── .dockerignore │ ├── .editorconfig │ ├── .env.development │ ├── .env.example │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .gitignore │ ├── .prettierignore │ ├── .releaserc │ ├── Dockerfile │ ├── LICENSE │ ├── app │ │ ├── client-layout.tsx │ │ ├── icon.svg │ │ ├── layout.tsx │ │ ├── scan │ │ │ └── [id] │ │ │ │ ├── page.tsx │ │ │ │ ├── scandetails.tsx │ │ │ │ └── tabs │ │ │ │ ├── CodeViewer.tsx │ │ │ │ ├── ScanDetails.tsx │ │ │ │ ├── ScanMetadetails.tsx │ │ │ │ ├── Screenshot.tsx │ │ │ │ └── datatable │ │ │ │ ├── columns.tsx │ │ │ │ ├── data-table.cy.jsx │ │ │ │ └── data-table.tsx │ │ ├── search │ │ │ ├── page.tsx │ │ │ └── search.tsx │ │ └── style.tsx │ ├── components.json │ ├── components │ │ ├── ActionDropdown.tsx │ │ ├── AutocompleteSearch.tsx │ │ ├── DataItem.cy.jsx │ │ ├── DataItem.tsx │ │ ├── DataItemActionMenu.tsx │ │ ├── ErrorBoundary.tsx │ │ ├── ImageFileComponent.tsx │ │ ├── LogoutButton.cy.jsx │ │ ├── LogoutButton.tsx │ │ ├── RefreshApiKeyDialog.tsx │ │ ├── ScanListItem.cy.jsx │ │ ├── ScanListItem.tsx │ │ ├── ScanStatus.tsx │ │ ├── ScannerSettingsForm.tsx │ │ ├── ScreenshotActionMenu.tsx │ │ ├── TraceViewer.tsx │ │ ├── UrlForm.cy.jsx │ │ ├── UrlForm.tsx │ │ ├── UserEditSheet.cy.jsx │ │ ├── UserEditSheet.tsx │ │ ├── accountSettings.tsx │ │ ├── icons.tsx │ │ ├── layout.tsx │ │ ├── main-nav.tsx │ │ ├── scannerSettingsCard.tsx │ │ ├── site-header.tsx │ │ ├── statusMessage.cy.jsx │ │ ├── statusMessage.tsx │ │ ├── theme-toggle.tsx │ │ ├── title.tsx │ │ └── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button-icon.tsx │ │ │ ├── button.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── container.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── generic-tooltip.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── slider.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle.tsx │ │ │ ├── tooltip.tsx │ │ │ └── typography │ │ │ ├── blockquote.tsx │ │ │ ├── h1.tsx │ │ │ ├── h2.tsx │ │ │ ├── h3.tsx │ │ │ ├── h4.tsx │ │ │ ├── inline-code.tsx │ │ │ ├── large.tsx │ │ │ ├── lead.tsx │ │ │ ├── list.tsx │ │ │ ├── p.tsx │ │ │ ├── small.tsx │ │ │ ├── subtle.tsx │ │ │ └── table.tsx │ ├── config │ │ └── site.ts │ ├── cypress.config.ts │ ├── cypress │ │ ├── e2e │ │ │ ├── 1-basic-features │ │ │ │ └── basic.cy.js │ │ │ └── api-tests │ │ │ │ └── scan.cy.js │ │ ├── fixtures │ │ │ ├── example.json │ │ │ ├── scannersresponse.json │ │ │ ├── scanresponseitem.json │ │ │ └── token.json │ │ ├── global.d.ts │ │ ├── support │ │ │ ├── commands.ts │ │ │ ├── component-index.html │ │ │ ├── component.ts │ │ │ └── e2e.ts │ │ └── tsconfig.json │ ├── docker-compose.yml │ ├── entrypoint.sh │ ├── hooks │ │ ├── use-api.ts │ │ ├── use-copyclipboard.ts │ │ ├── use-file.ts │ │ ├── use-statusmessage.ts │ │ ├── use-sub.ts │ │ └── use-toast.ts │ ├── jest.config.ts │ ├── lib │ │ ├── FileTokenProvider.tsx │ │ ├── api_error.ts │ │ ├── pocketbase.ts │ │ ├── tips.ts │ │ └── utils.ts │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── account │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── login │ │ │ └── index.tsx │ │ └── settings │ │ │ └── index.tsx │ ├── postcss.config.js │ ├── prettier.config.js │ ├── public │ │ ├── favicon.svg │ │ ├── icon_only.svg │ │ ├── scan-in-progress.png │ │ ├── webhood-logo-icon-text-paths-dark.svg │ │ ├── webhood-logo-icon-text-paths.svg │ │ └── x.png │ ├── styles │ │ └── globals.css │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── types │ │ ├── nav.ts │ │ └── token.ts │ └── yarn.lock ├── scanner │ ├── .env.template │ ├── .gitignore │ ├── .mocharc.json │ ├── .releaserc │ ├── Dockerfile │ ├── Dockerfile.dev │ ├── LICENSE │ ├── chrome.json │ ├── docker-compose.yaml │ ├── extensions │ │ ├── README.md │ │ └── fihnjjcciajhdojfnbdddfaoknhalnja │ │ │ ├── LICENSE │ │ │ ├── _locales │ │ │ ├── bg │ │ │ │ └── messages.json │ │ │ ├── cs │ │ │ │ └── messages.json │ │ │ ├── da │ │ │ │ └── messages.json │ │ │ ├── de │ │ │ │ └── messages.json │ │ │ ├── en │ │ │ │ └── messages.json │ │ │ ├── es │ │ │ │ └── messages.json │ │ │ ├── fr │ │ │ │ └── messages.json │ │ │ ├── hr │ │ │ │ └── messages.json │ │ │ ├── hu │ │ │ │ └── messages.json │ │ │ ├── it │ │ │ │ └── messages.json │ │ │ ├── ja │ │ │ │ └── messages.json │ │ │ ├── lt │ │ │ │ └── messages.json │ │ │ ├── nb │ │ │ │ └── messages.json │ │ │ ├── nl │ │ │ │ └── messages.json │ │ │ ├── nn │ │ │ │ └── messages.json │ │ │ ├── no │ │ │ │ └── messages.json │ │ │ ├── pl │ │ │ │ └── messages.json │ │ │ ├── pt │ │ │ │ └── messages.json │ │ │ ├── ro │ │ │ │ └── messages.json │ │ │ ├── ru │ │ │ │ └── messages.json │ │ │ ├── sk │ │ │ │ └── messages.json │ │ │ ├── sv │ │ │ │ └── messages.json │ │ │ └── uk │ │ │ │ └── messages.json │ │ │ ├── data │ │ │ ├── context-menu.js │ │ │ ├── css │ │ │ │ └── common.css │ │ │ ├── js │ │ │ │ ├── common.js │ │ │ │ ├── common2.js │ │ │ │ ├── common3.js │ │ │ │ ├── common4.js │ │ │ │ ├── common5.js │ │ │ │ ├── common6.js │ │ │ │ ├── common7.js │ │ │ │ ├── common8.js │ │ │ │ └── embeds.js │ │ │ ├── menu │ │ │ │ ├── index.html │ │ │ │ ├── script.js │ │ │ │ └── style.css │ │ │ ├── options.html │ │ │ ├── options.js │ │ │ └── rules.js │ │ │ ├── extension_3_4_6_0.crx │ │ │ ├── icons │ │ │ ├── 128.png │ │ │ ├── 16.png │ │ │ ├── 32.png │ │ │ └── 48.png │ │ │ └── manifest.json │ ├── package.json │ ├── src │ │ ├── config │ │ │ └── main.ts │ │ ├── errors.ts │ │ ├── logging.ts │ │ ├── main.ts │ │ ├── rateConfig.ts │ │ ├── server.ts │ │ └── utils │ │ │ ├── captcha.ts │ │ │ ├── dnsUtils.ts │ │ │ ├── memoryAuthStore.ts │ │ │ ├── other.ts │ │ │ ├── pbUtils.ts │ │ │ └── puppeteerUtils.ts │ ├── test │ │ ├── browserTest.ts │ │ ├── test.ts │ │ ├── utils.ts │ │ └── utilsTest.ts │ ├── tsconfig.json │ └── yarn.lock └── types │ ├── db.ts │ ├── index.ts │ ├── package.json │ └── yarn.lock └── yarn.lock /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image":"mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/devcontainers/features/docker-in-docker:2": {} 5 | }, 6 | "customizations": { 7 | "codespaces": { 8 | "openFiles": [ 9 | "README.md" 10 | ] 11 | } 12 | }, 13 | "hostRequirements": { 14 | "cpus": 8, 15 | "memory": "8gb", 16 | "storage": "32gb" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ############ 2 | # Required configuration 3 | ############ 4 | 5 | SCANNER_TOKEN= 6 | 7 | ############ 8 | # Optional configuration 9 | ############ 10 | 11 | EXTERNAL_URL= 12 | # EXTERNAL_URL=http://backend:8090 # for development and testing 13 | 14 | # SCANNER_LOG_LEVEL is one of debug, info, warn, error, fatal 15 | SCANNER_LOG_LEVEL= 16 | # SCANNER_NO_PRIVATE_IPS is one of true, false 17 | SCANNER_NO_PRIVATE_IPS= 18 | 19 | WEBHOOD_HTTP_PORT= 20 | WEBHOOD_HTTPS_PORT= 21 | WEBHOOD_TLS_CERT= 22 | WEBHOOD_TLS_KEY= 23 | 24 | HTTP_PROXY= 25 | HTTPS_PROXY= 26 | NO_PROXY= -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: markusleh 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Scanner (npm and Docker) 4 | - package-ecosystem: "npm" 5 | directory: "/src/scanner/" # Location of package manifests 6 | schedule: 7 | interval: "weekly" 8 | allow: 9 | - dependency-type: "production" 10 | 11 | 12 | - package-ecosystem: "docker" 13 | directory: "/src/scanner/" 14 | schedule: 15 | interval: "weekly" 16 | 17 | # Core (npm and Docker) 18 | - package-ecosystem: "npm" 19 | directory: "/src/core/" 20 | schedule: 21 | interval: "weekly" 22 | allow: 23 | - dependency-type: "production" 24 | 25 | 26 | - package-ecosystem: "docker" 27 | directory: "/src/core/" 28 | schedule: 29 | interval: "weekly" 30 | 31 | # Backend (go and Docker) 32 | - package-ecosystem: "docker" 33 | directory: "/src/backend/" 34 | schedule: 35 | interval: "weekly" 36 | 37 | - package-ecosystem: gomod 38 | directory: /src/backend/src/ 39 | schedule: 40 | interval: weekly 41 | -------------------------------------------------------------------------------- /.github/workflows/backend.dev.yml: -------------------------------------------------------------------------------- 1 | name: backend.dev-build-push 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'main' 7 | paths: 8 | - 'src/backend/**' 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }}/backend 13 | 14 | jobs: 15 | docker: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v3 21 | - 22 | name: Docker meta 23 | id: meta 24 | uses: docker/metadata-action@v4 25 | with: 26 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 27 | tags: | 28 | type=ref,event=branch 29 | type=ref,event=pr 30 | type=raw,value=dev,enable=${{ endsWith(github.ref, '-dev') }} 31 | type=sha 32 | 33 | - name: Login to GHCR 34 | uses: docker/login-action@v2 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - 41 | name: Build and push 42 | uses: docker/build-push-action@v4 43 | with: 44 | context: src/backend 45 | push: ${{ github.event_name != 'pull_request' }} 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /.github/workflows/core.dev.yml: -------------------------------------------------------------------------------- 1 | name: core.dev-build-push 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'main' 7 | paths: 8 | - 'src/core/**' 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }}/core 13 | 14 | jobs: 15 | docker: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v3 21 | - 22 | name: Docker meta 23 | id: meta 24 | uses: docker/metadata-action@v4 25 | with: 26 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 27 | tags: | 28 | type=ref,event=branch 29 | type=ref,event=pr 30 | type=raw,value=dev,enable=${{ endsWith(github.ref, '-dev') }} 31 | type=sha 32 | 33 | - name: Login to GHCR 34 | uses: docker/login-action@v2 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - 41 | name: Build and push 42 | uses: docker/build-push-action@v4 43 | with: 44 | context: src/core 45 | push: ${{ github.event_name != 'pull_request' }} 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /.github/workflows/cypress-e2e.dev.yml: -------------------------------------------------------------------------------- 1 | name: Cypress E2E tests 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | workflow_dispatch: # manual 7 | pull_request: 8 | 9 | jobs: 10 | cypress-e2e: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | containers: [1] 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | 20 | - uses: actions/setup-go@v4 21 | with: 22 | go-version: '^1.21.0' 23 | 24 | - name: Start backend 25 | run: ./scripts/setup-e2e.sh 26 | 27 | - name: Cypress run 28 | uses: cypress-io/github-action@v6 29 | with: 30 | #wait-on: 'http://localhost:8090' 31 | working-directory: src/core 32 | env: 33 | NEXT_PUBLIC_API_URL: http://localhost:8090 34 | -------------------------------------------------------------------------------- /.github/workflows/cyress-core.dev.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Component tests 2 | on: 3 | push: 4 | branches-ignore: 5 | - 'main' 6 | paths: 7 | - 'src/core/**' 8 | workflow_dispatch: # manual 9 | pull_request: 10 | 11 | 12 | jobs: 13 | cypress-run: 14 | runs-on: ubuntu-latest 15 | # Runs tests in parallel with matrix strategy https://docs.cypress.io/guides/guides/parallelization 16 | # https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs 17 | # Also see warning here https://github.com/cypress-io/github-action#parallel 18 | strategy: 19 | fail-fast: false # https://github.com/cypress-io/github-action/issues/48 20 | matrix: 21 | containers: [1] # Uses 1 parallel instance 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | - name: Cypress run 26 | # Uses the official Cypress GitHub action https://github.com/cypress-io/github-action 27 | uses: cypress-io/github-action@v6 28 | with: 29 | # Starts web server for E2E tests - replace with your own server invocation 30 | # https://docs.cypress.io/guides/continuous-integration/introduction#Boot-your-server 31 | # start: yarn run dev 32 | # wait-on: 'http://localhost:3000' # Waits for above 33 | # Records to Cypress Cloud 34 | # https://docs.cypress.io/guides/cloud/projects#Set-up-a-project-to-record 35 | # record: true 36 | # parallel: true # Runs test in parallel using settings above 37 | working-directory: src/core 38 | component: true 39 | env: 40 | # For recording and parallelization to work you must set your CYPRESS_RECORD_KEY 41 | # in GitHub repo → Settings → Secrets → Actions 42 | CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} 43 | # Creating a token https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/publish.prod.yml: -------------------------------------------------------------------------------- 1 | name: Build & publish Webhood images 2 | on: 3 | release: 4 | types: [published] 5 | 6 | pull_request: 7 | branches: 8 | - 'main' 9 | types: [opened, reopened, review_requested] 10 | 11 | env: 12 | REGISTRY: ghcr.io 13 | IMAGE_PATH: ghcr.io/${{ github.repository }} 14 | 15 | jobs: 16 | build-and-push-image: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - context: src/core 23 | image: ghcr.io/webhood-io/webhood/core 24 | - context: src/backend 25 | image: ghcr.io/webhood-io/webhood/backend 26 | - context: src/scanner 27 | image: ghcr.io/webhood-io/webhood/scanner 28 | permissions: 29 | contents: read 30 | packages: write 31 | 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v2 35 | 36 | - name: Log in to the Container registry 37 | uses: docker/login-action@v2 38 | with: 39 | registry: ${{ env.REGISTRY }} 40 | username: ${{ github.actor }} 41 | password: ${{ secrets.GITHUB_TOKEN }} 42 | 43 | - name: Extract metadata (tags, labels) for Docker 44 | id: meta 45 | uses: docker/metadata-action@v4 46 | with: 47 | images: ${{ matrix.image }} 48 | tags: | 49 | type=ref,event=tag 50 | type=ref,event=branch 51 | type=ref,event=pr 52 | type=raw,value=latest,enable={{is_default_branch}} 53 | 54 | - name: Build and push Docker image 55 | uses: docker/build-push-action@v4 56 | with: 57 | context: ${{ matrix.context }} 58 | push: true 59 | tags: ${{ steps.meta.outputs.tags }} 60 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/scanner-tests.dev.yml: -------------------------------------------------------------------------------- 1 | name: Scanner Mocha tests 2 | on: 3 | workflow_dispatch: # manual 4 | push: 5 | branches-ignore: 6 | - 'main' 7 | paths: 8 | - 'src/scanner/**' 9 | pull_request: 10 | 11 | jobs: 12 | scanner-test: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 10 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - uses: browser-actions/setup-chrome@v1 19 | - run: chrome --version 20 | - name: Use Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '21.x' 24 | - name: Start backend 25 | run: ./scripts/setup-scanner-test-env.sh 26 | 27 | - name: Mocha run 28 | run: ./scripts/run-scanner-test.sh -------------------------------------------------------------------------------- /.github/workflows/scanner.dev.yml: -------------------------------------------------------------------------------- 1 | name: scanner.dev-build-push 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'main' 7 | paths: 8 | - 'src/scanner/**' 9 | - '!src/scanner/test/**' 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }}/scanner 13 | 14 | jobs: 15 | docker: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v4 21 | - 22 | name: Docker meta 23 | id: meta 24 | uses: docker/metadata-action@v5 25 | with: 26 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 27 | tags: | 28 | type=ref,event=branch 29 | type=ref,event=pr 30 | type=raw,value=dev,enable=${{ endsWith(github.ref, '-dev') }} 31 | type=sha 32 | 33 | - name: Login to GHCR 34 | uses: docker/login-action@v3 35 | with: 36 | registry: ${{ env.REGISTRY }} 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - 41 | name: Build and push 42 | uses: docker/build-push-action@v5 43 | with: 44 | context: src/scanner 45 | push: ${{ github.event_name != 'pull_request' }} 46 | tags: ${{ steps.meta.outputs.tags }} 47 | labels: ${{ steps.meta.outputs.labels }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | test.http 3 | docker-compose.override.yml 4 | node_modules 5 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.organizeImports": true, 5 | "source.sortMembers": true 6 | }, 7 | "eslint.alwaysShowStatus": true, 8 | "editor.formatOnSave": true, 9 | "prettier.requireConfig": true, 10 | "prettier.configPath": "./src/core/prettier.config.js", 11 | "prettier.prettierPath": "./src/core/node_modules/prettier/index.cjs", 12 | } -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | In case you discover a vulnerability in of the code in this repository (latest version or any past [versions](https://github.com/webhood-io/webhood/tags) or discover applicable vulnerabilities in any of the dependencies that are in use in the supported versions, contact security@webhood.io. We wil respond to all communications swiftly. 6 | -------------------------------------------------------------------------------- /docker-compose.v1.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | scanner: 5 | container_name: webhood-scanner 6 | image: ghcr.io/webhood-io/webhood/scanner:latest 7 | restart: always 8 | environment: 9 | ENDPOINT: http://backend:8090 10 | SCANNER_TOKEN: ${SCANNER_TOKEN} 11 | networks: 12 | - rest 13 | security_opt: 14 | # Use seccomp to restrict the syscalls that the container can make for Chrome 15 | # This allows us to run chrome with sandboxing enabled without having to run the whole container as root 16 | - seccomp=./files/chrome.json 17 | 18 | core: 19 | container_name: webhood-core 20 | image: ghcr.io/webhood-io/webhood/core:latest 21 | restart: always 22 | ports: 23 | - "3000:3000" 24 | environment: 25 | API_URL: ${EXTERNAL_URL} 26 | SELF_REGISTER: "false" 27 | HOSTNAME: 0.0.0.0 # bind to all interfaces. This is needed for nextjs to work. Note that this by itself does not expose the docker directly. 28 | # Ignore TLS errors for self-signed certificates between the UI and the internal admin API 29 | # Note that this does not affect the TLS connection between the UI and the browser 30 | NODE_TLS_REJECT_UNAUTHORIZED: 0 31 | networks: 32 | - frontend 33 | 34 | backend: 35 | container_name: webhood-backend 36 | image: ghcr.io/webhood-io/webhood/backend:latest 37 | restart: always 38 | environment: 39 | # https://github.com/pocketbase/pocketbase/releases/tag/v0.17.3 40 | # TODO: Remove once fixed in upstream 41 | - TMPDIR=/pb/pb_data/storage/xeb56gcfepjsjb4/ 42 | volumes: 43 | - data:/pb/pb_data 44 | ports: 45 | - "8000:8090" 46 | command: 47 | - serve 48 | - --http=0.0.0.0:8090 49 | healthcheck: 50 | test: ["CMD", "curl", "-f", "http://localhost:8090/_/"] 51 | interval: 10s 52 | timeout: 10s 53 | retries: 5 54 | networks: 55 | - frontend 56 | - rest 57 | 58 | volumes: 59 | data: 60 | 61 | networks: 62 | db: 63 | rest: 64 | frontend: 65 | -------------------------------------------------------------------------------- /kong.yml: -------------------------------------------------------------------------------- 1 | _format_version: "1.1" 2 | 3 | ### 4 | ### API Routes 5 | ### 6 | services: 7 | ## Secure Next routes 8 | - name: webhood-core-v1 9 | url: http://core:3000 10 | routes: 11 | ## All other data such as static files 12 | - name: webhood-beta-all 13 | paths: 14 | - / 15 | plugins: 16 | - name: cors 17 | 18 | ## Backend 19 | - name: webhood-backend-v1 20 | url: http://backend:8090 21 | routes: 22 | - name: api 23 | strip_path: false 24 | paths: 25 | - /api/ -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webhood", 3 | "version": "0.2", 4 | "description": "", 5 | "license": "GPL-3.0-only", 6 | "private": true, 7 | "scripts": { 8 | "test:e2e": "./scripts/setup-e2e.sh" 9 | }, 10 | "devDependencies": { 11 | "cypress": "latest" 12 | } 13 | } -------------------------------------------------------------------------------- /scripts/dev/create_admin_user.sh: -------------------------------------------------------------------------------- 1 | # Never user this in production, local machine development only 2 | docker compose -f docker-compose.dev.yml run backend create_user -u admin -p password -------------------------------------------------------------------------------- /scripts/dev/create_scanner_token.sh: -------------------------------------------------------------------------------- 1 | docker compose -f docker-compose.dev.yml run backend create_scanner_token -------------------------------------------------------------------------------- /scripts/run-scanner-test.sh: -------------------------------------------------------------------------------- 1 | cd src/scanner 2 | yarn 3 | yarn test -------------------------------------------------------------------------------- /scripts/setup-e2e.sh: -------------------------------------------------------------------------------- 1 | cd src/core 2 | yarn install --frozen-lockfile 3 | yarn run dev & 4 | cd ../backend 5 | cp -r migrations src/pb_migrations 6 | cd src 7 | go version 8 | go run main.go migrate 9 | go run main.go create_user -u admin -p password123 -e test@example.com 10 | go run main.go create_scanner -u e2etest 11 | SCANNER_TOKEN=$(go run main.go create_scanner_token -u e2etest 2>&1|grep SCANNER_TOKEN|cut -d '=' -f2) 12 | # echo as {"SCANNER_TOKEN":""} 13 | echo "{\"SCANNER_TOKEN\":\"${SCANNER_TOKEN}\"}" > ../../core/cypress.env.json 14 | # replace the token in the ".env" file with the one printed by the previous command 15 | #sed -i "s/SCANNER_TOKEN=.*/$TOKEN/" .env 16 | go run main.go serve & -------------------------------------------------------------------------------- /scripts/setup-scanner-test-env.sh: -------------------------------------------------------------------------------- 1 | cd src/backend 2 | cp -r migrations src/pb_migrations 3 | cd src 4 | go run main.go migrate 5 | go run main.go create_scanner -u scannertest 6 | go run main.go create_scanner_token -u scannertest 2>&1|grep SCANNER_TOKEN >> ../../scanner/.env 7 | echo "LOG_LEVEL=debug" >> ../../scanner/.env 8 | echo "ENDPOINT=http://127.0.0.1:8090" >> ../../scanner/.env 9 | go run main.go serve & -------------------------------------------------------------------------------- /src/backend/.gitignore: -------------------------------------------------------------------------------- 1 | ### Go ### 2 | # If you prefer the allow list template instead of the deny list, see community template: 3 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 4 | # 5 | # Binaries for programs and plugins 6 | *.exe 7 | *.exe~ 8 | *.dll 9 | *.so 10 | *.dylib 11 | 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | 21 | # Go workspace file 22 | go.work 23 | 24 | ### macOS ### 25 | # General 26 | .DS_Store 27 | .AppleDouble 28 | .LSOverride 29 | 30 | # Icon must end with two \r 31 | Icon 32 | 33 | 34 | # Thumbnails 35 | ._* 36 | 37 | # Files that might appear in the root of a volume 38 | .DocumentRevisions-V100 39 | .fseventsd 40 | .Spotlight-V100 41 | .TemporaryItems 42 | .Trashes 43 | .VolumeIcon.icns 44 | .com.apple.timemachine.donotpresent 45 | 46 | # Directories potentially created on remote AFP share 47 | .AppleDB 48 | .AppleDesktop 49 | Network Trash Folder 50 | Temporary Items 51 | .apdisk 52 | 53 | ### macOS Patch ### 54 | # iCloud generated files 55 | *.icloud 56 | .idea 57 | .vscode 58 | local 59 | 60 | # pb_data 61 | pb_data 62 | pb_migrations -------------------------------------------------------------------------------- /src/backend/.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/github" 9 | ] 10 | } -------------------------------------------------------------------------------- /src/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-bullseye as builder 2 | 3 | # Create and change to the app directory. 4 | WORKDIR /app 5 | 6 | # Retrieve application dependencies. 7 | # This allows the container build to reuse cached dependencies. 8 | # Expecting to copy go.mod and if present go.sum. 9 | COPY src/go.* ./ 10 | COPY src/webhood ./webhood 11 | RUN go mod download 12 | 13 | # Copy local code to the container image. 14 | COPY src/*.go ./ 15 | COPY src/webhood ./webhood 16 | 17 | # Build the binary. 18 | RUN go build -v -o backend 19 | 20 | FROM debian:12-slim as base 21 | 22 | RUN apt-get update && apt-get install -y \ 23 | curl \ 24 | unzip \ 25 | ca-certificates \ 26 | && rm -rf /var/lib/apt/lists/* 27 | 28 | WORKDIR /pb/ 29 | 30 | COPY --from=builder /app/backend ./backend 31 | 32 | ADD ./migrations ./pb_migrations 33 | 34 | ENTRYPOINT [ "./backend" ] 35 | CMD [ "serve" ] -------------------------------------------------------------------------------- /src/backend/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | volumes: 4 | data: 5 | migrations: 6 | 7 | services: 8 | core: 9 | container_name: webhood-backend 10 | build: . 11 | restart: always 12 | ports: 13 | - "8089:8090" 14 | volumes: 15 | - data:/pb/pb_data 16 | - migrations:/pb/pb_migrations 17 | command: 18 | - serve 19 | - --http=0.0.0.0:8090 -------------------------------------------------------------------------------- /src/backend/migrations/1683054191_created_api_tokens.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const collection = new Collection({ 3 | "id": "08gx30km4eghscl", 4 | "created": "2023-05-02 19:03:11.623Z", 5 | "updated": "2023-05-02 19:03:11.623Z", 6 | "name": "api_tokens", 7 | "type": "auth", 8 | "system": false, 9 | "schema": [ 10 | { 11 | "system": false, 12 | "id": "8rphw9ci", 13 | "name": "role", 14 | "type": "select", 15 | "required": false, 16 | "unique": false, 17 | "options": { 18 | "maxSelect": 1, 19 | "values": [ 20 | "scanner", 21 | "api_user" 22 | ] 23 | } 24 | }, 25 | { 26 | "system": false, 27 | "id": "nl9rbqwn", 28 | "name": "expires", 29 | "type": "date", 30 | "required": false, 31 | "unique": false, 32 | "options": { 33 | "min": "", 34 | "max": "" 35 | } 36 | } 37 | ], 38 | "indexes": [], 39 | "listRule": "@request.auth.role = 'admin'", 40 | "viewRule": "@request.auth.id = id || @request.auth.role = 'admin'", 41 | "createRule": "@request.auth.role = 'admin'", 42 | "updateRule": "@request.auth.role = 'admin'", 43 | "deleteRule": "@request.auth.role = 'admin'", 44 | "options": { 45 | "allowEmailAuth": false, 46 | "allowOAuth2Auth": false, 47 | "allowUsernameAuth": false, 48 | "exceptEmailDomains": [], 49 | "manageRule": "@request.auth.role = 'admin'", 50 | "minPasswordLength": 8, 51 | "onlyEmailDomains": [], 52 | "requireEmail": false 53 | } 54 | }); 55 | 56 | return Dao(db).saveCollection(collection); 57 | }, (db) => { 58 | const dao = new Dao(db); 59 | const collection = dao.findCollectionByNameOrId("08gx30km4eghscl"); 60 | 61 | return dao.deleteCollection(collection); 62 | }) 63 | -------------------------------------------------------------------------------- /src/backend/migrations/1683054191_created_scanners.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const collection = new Collection({ 3 | "id": "4mw0oi15o9akrgh", 4 | "created": "2023-05-02 19:03:11.623Z", 5 | "updated": "2023-05-02 19:03:11.623Z", 6 | "name": "scanners", 7 | "type": "base", 8 | "system": false, 9 | "schema": [ 10 | { 11 | "system": false, 12 | "id": "kut374js", 13 | "name": "config", 14 | "type": "json", 15 | "required": false, 16 | "unique": false, 17 | "options": {} 18 | } 19 | ], 20 | "indexes": [], 21 | "listRule": "@request.auth.id != ''", 22 | "viewRule": "@request.auth.id != ''", 23 | "createRule": null, 24 | "updateRule": "@request.auth.role = 'admin'", 25 | "deleteRule": null, 26 | "options": {} 27 | }); 28 | 29 | return Dao(db).saveCollection(collection); 30 | }, (db) => { 31 | const dao = new Dao(db); 32 | const collection = dao.findCollectionByNameOrId("4mw0oi15o9akrgh"); 33 | 34 | return dao.deleteCollection(collection); 35 | }) 36 | -------------------------------------------------------------------------------- /src/backend/migrations/1683054191_updated_users.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("_pb_users_auth_") 4 | 5 | collection.listRule = "@request.auth.role = 'admin'" 6 | collection.viewRule = "@request.auth.id = id || @request.auth.role = 'admin'" 7 | collection.updateRule = "@request.auth.id = id || @request.auth.role = 'admin'" 8 | collection.deleteRule = "@request.auth.id = id || @request.auth.role = 'admin'" 9 | collection.options = { 10 | "allowEmailAuth": true, 11 | "allowOAuth2Auth": true, 12 | "allowUsernameAuth": true, 13 | "exceptEmailDomains": null, 14 | "manageRule": "@request.auth.role = 'admin'", 15 | "minPasswordLength": 8, 16 | "onlyEmailDomains": null, 17 | "requireEmail": false 18 | } 19 | 20 | // add 21 | collection.schema.addField(new SchemaField({ 22 | "system": false, 23 | "id": "snl5ngau", 24 | "name": "role", 25 | "type": "select", 26 | "required": false, 27 | "unique": false, 28 | "options": { 29 | "maxSelect": 1, 30 | "values": [ 31 | "admin", 32 | "user" 33 | ] 34 | } 35 | })) 36 | 37 | return dao.saveCollection(collection) 38 | }, (db) => { 39 | const dao = new Dao(db) 40 | const collection = dao.findCollectionByNameOrId("_pb_users_auth_") 41 | 42 | collection.listRule = null 43 | collection.viewRule = null 44 | collection.updateRule = null 45 | collection.deleteRule = null 46 | collection.options = { 47 | "allowEmailAuth": true, 48 | "allowOAuth2Auth": true, 49 | "allowUsernameAuth": true, 50 | "exceptEmailDomains": null, 51 | "manageRule": null, 52 | "minPasswordLength": 8, 53 | "onlyEmailDomains": null, 54 | "requireEmail": false 55 | } 56 | 57 | // remove 58 | collection.schema.removeField("snl5ngau") 59 | 60 | return dao.saveCollection(collection) 61 | }) 62 | -------------------------------------------------------------------------------- /src/backend/migrations/1683054193_create_scanner_user.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("api_tokens") 4 | const obj = new Record(collection) 5 | 6 | obj.set('id', 'mzven27v6pg29mx') 7 | obj.set('username', 'scanner1') 8 | obj.set("role", "scanner") 9 | 10 | return dao.saveRecord(obj) 11 | }, (db) => { 12 | const dao = new Dao(db) 13 | 14 | const obj = dao.findRecordById("api_tokens", "mzven27v6pg29mx") 15 | 16 | return dao.deleteRecord(obj) 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /src/backend/migrations/1704268370_updated_api_tokens.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("08gx30km4eghscl") 4 | 5 | collection.options = { 6 | "allowEmailAuth": false, 7 | "allowOAuth2Auth": false, 8 | "allowUsernameAuth": false, 9 | "exceptEmailDomains": [], 10 | "manageRule": "@request.auth.role = 'admin'", 11 | "minPasswordLength": 8, 12 | "onlyEmailDomains": [], 13 | "onlyVerified": false, 14 | "requireEmail": false 15 | } 16 | 17 | // add 18 | collection.schema.addField(new SchemaField({ 19 | "system": false, 20 | "id": "wrdpd08g", 21 | "name": "config", 22 | "type": "relation", 23 | "required": false, 24 | "presentable": false, 25 | "unique": false, 26 | "options": { 27 | "collectionId": "4mw0oi15o9akrgh", 28 | "cascadeDelete": false, 29 | "minSelect": null, 30 | "maxSelect": 1, 31 | "displayFields": null 32 | } 33 | })) 34 | 35 | return dao.saveCollection(collection) 36 | }, (db) => { 37 | const dao = new Dao(db) 38 | const collection = dao.findCollectionByNameOrId("08gx30km4eghscl") 39 | 40 | collection.options = { 41 | "allowEmailAuth": false, 42 | "allowOAuth2Auth": false, 43 | "allowUsernameAuth": false, 44 | "exceptEmailDomains": [], 45 | "manageRule": "@request.auth.role = 'admin'", 46 | "minPasswordLength": 8, 47 | "onlyEmailDomains": [], 48 | "requireEmail": false 49 | } 50 | 51 | // remove 52 | collection.schema.removeField("wrdpd08g") 53 | 54 | return dao.saveCollection(collection) 55 | }) 56 | -------------------------------------------------------------------------------- /src/backend/migrations/1704278503_add_scans_options.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 4 | 5 | // add 6 | collection.schema.addField(new SchemaField({ 7 | "system": false, 8 | "id": "ufznoksp", 9 | "name": "options", 10 | "type": "json", 11 | "required": false, 12 | "presentable": false, 13 | "unique": false, 14 | "options": { 15 | "maxSize": 2000000 16 | } 17 | })) 18 | 19 | return dao.saveCollection(collection) 20 | }, (db) => { 21 | const dao = new Dao(db) 22 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 23 | 24 | // remove 25 | collection.schema.removeField("ufznoksp") 26 | 27 | return dao.saveCollection(collection) 28 | }) 29 | -------------------------------------------------------------------------------- /src/backend/migrations/1704282038_add_scanners_name.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("4mw0oi15o9akrgh") 4 | 5 | // add 6 | collection.schema.addField(new SchemaField({ 7 | "system": false, 8 | "id": "kd1hgvnt", 9 | "name": "name", 10 | "type": "text", 11 | "required": false, 12 | "presentable": false, 13 | "unique": false, 14 | "options": { 15 | "min": null, 16 | "max": null, 17 | "pattern": "" 18 | } 19 | })) 20 | 21 | return dao.saveCollection(collection) 22 | }, (db) => { 23 | const dao = new Dao(db) 24 | const collection = dao.findCollectionByNameOrId("4mw0oi15o9akrgh") 25 | 26 | // remove 27 | collection.schema.removeField("kd1hgvnt") 28 | 29 | return dao.saveCollection(collection) 30 | }) 31 | -------------------------------------------------------------------------------- /src/backend/migrations/1704282039_add_name_default_scanner.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const obj = dao.findRecordById("api_tokens", "mzven27v6pg29mx") 4 | 5 | obj.set('name', 'internal scanner') 6 | 7 | return dao.saveRecord(obj) 8 | }, (db) => { 9 | const dao = new Dao(db) 10 | 11 | const obj = dao.findRecordById("api_tokens", "mzven27v6pg29mx") 12 | obj.set('name', null) 13 | 14 | return dao.saveRecord(obj) 15 | } 16 | ) 17 | -------------------------------------------------------------------------------- /src/backend/migrations/1708001745_updated_scans.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 4 | 5 | // update 6 | collection.schema.addField(new SchemaField({ 7 | "system": false, 8 | "id": "jhgy6ntr", 9 | "name": "status", 10 | "type": "select", 11 | "required": false, 12 | "presentable": false, 13 | "unique": false, 14 | "options": { 15 | "maxSelect": 1, 16 | "values": [ 17 | "pending", 18 | "running", 19 | "error", 20 | "done", 21 | "queued" 22 | ] 23 | } 24 | })) 25 | 26 | return dao.saveCollection(collection) 27 | }, (db) => { 28 | const dao = new Dao(db) 29 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 30 | 31 | // update 32 | collection.schema.addField(new SchemaField({ 33 | "system": false, 34 | "id": "jhgy6ntr", 35 | "name": "status", 36 | "type": "select", 37 | "required": false, 38 | "presentable": false, 39 | "unique": false, 40 | "options": { 41 | "maxSelect": 1, 42 | "values": [ 43 | "pending", 44 | "running", 45 | "error", 46 | "done" 47 | ] 48 | } 49 | })) 50 | 51 | return dao.saveCollection(collection) 52 | }) 53 | -------------------------------------------------------------------------------- /src/backend/migrations/1708432935_added_scanstats.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const collection = new Collection({ 3 | "id": "p52hzwk25m1kxy8", 4 | "created": "2024-02-20 12:48:50.607Z", 5 | "updated": "2024-02-20 12:48:50.607Z", 6 | "name": "scanstats", 7 | "type": "view", 8 | "system": false, 9 | "schema": [ 10 | { 11 | "system": false, 12 | "id": "slatvton", 13 | "name": "count_items", 14 | "type": "number", 15 | "required": false, 16 | "presentable": false, 17 | "unique": false, 18 | "options": { 19 | "min": null, 20 | "max": null, 21 | "noDecimal": false 22 | } 23 | }, 24 | { 25 | "system": false, 26 | "id": "iuj4eroc", 27 | "name": "status", 28 | "type": "select", 29 | "required": false, 30 | "presentable": false, 31 | "unique": false, 32 | "options": { 33 | "maxSelect": 1, 34 | "values": [ 35 | "pending", 36 | "running", 37 | "error", 38 | "done", 39 | "queued" 40 | ] 41 | } 42 | } 43 | ], 44 | "indexes": [], 45 | "listRule": "@request.auth.id != ''", 46 | "viewRule": "@request.auth.id != ''", 47 | "createRule": null, 48 | "updateRule": null, 49 | "deleteRule": null, 50 | "options": { 51 | "query": "SELECT COUNT(id) as count_items, status, (ROW_NUMBER() OVER()) as id\nFROM scans\nGROUP BY status" 52 | } 53 | }); 54 | 55 | return Dao(db).saveCollection(collection); 56 | }, (db) => { 57 | const dao = new Dao(db); 58 | const collection = dao.findCollectionByNameOrId("p52hzwk25m1kxy8"); 59 | 60 | return dao.deleteCollection(collection); 61 | }) 62 | -------------------------------------------------------------------------------- /src/backend/migrations/1708514819_updated_scans_html_size.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 4 | 5 | // update 6 | collection.schema.addField(new SchemaField({ 7 | "system": false, 8 | "id": "5kqujuy9", 9 | "name": "html", 10 | "type": "file", 11 | "required": false, 12 | "presentable": false, 13 | "unique": false, 14 | "options": { 15 | "mimeTypes": [], 16 | "thumbs": [], 17 | "maxSelect": 99, 18 | "maxSize": 20000000, 19 | "protected": true 20 | } 21 | })) 22 | 23 | return dao.saveCollection(collection) 24 | }, (db) => { 25 | const dao = new Dao(db) 26 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 27 | 28 | // update 29 | collection.schema.addField(new SchemaField({ 30 | "system": false, 31 | "id": "5kqujuy9", 32 | "name": "html", 33 | "type": "file", 34 | "required": false, 35 | "presentable": false, 36 | "unique": false, 37 | "options": { 38 | "mimeTypes": [], 39 | "thumbs": [], 40 | "maxSelect": 99, 41 | "maxSize": 5242880, 42 | "protected": true 43 | } 44 | })) 45 | 46 | return dao.saveCollection(collection) 47 | }) 48 | -------------------------------------------------------------------------------- /src/backend/migrations/1710102971_updated_scans_scandata.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 4 | 5 | // add 6 | collection.schema.addField(new SchemaField({ 7 | "system": false, 8 | "id": "qswfr5ky", 9 | "name": "scandata", 10 | "type": "json", 11 | "required": false, 12 | "presentable": false, 13 | "unique": false, 14 | "options": { 15 | "maxSize": 2000000 16 | } 17 | })) 18 | 19 | return dao.saveCollection(collection) 20 | }, (db) => { 21 | const dao = new Dao(db) 22 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 23 | 24 | // remove 25 | collection.schema.removeField("qswfr5ky") 26 | 27 | return dao.saveCollection(collection) 28 | }) 29 | -------------------------------------------------------------------------------- /src/backend/migrations/1711023208_add_files_to_scans.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 4 | 5 | // add 6 | collection.schema.addField(new SchemaField({ 7 | "system": false, 8 | "id": "ph9beqde", 9 | "name": "files", 10 | "type": "file", 11 | "required": false, 12 | "presentable": false, 13 | "unique": false, 14 | "options": { 15 | "mimeTypes": [], 16 | "thumbs": [], 17 | "maxSelect": 99, 18 | "maxSize": 50000000, 19 | "protected": true 20 | } 21 | })) 22 | 23 | return dao.saveCollection(collection) 24 | }, (db) => { 25 | const dao = new Dao(db) 26 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 27 | 28 | // remove 29 | collection.schema.removeField("ph9beqde") 30 | 31 | return dao.saveCollection(collection) 32 | }) 33 | -------------------------------------------------------------------------------- /src/backend/migrations/1712252550_generate_random_admin.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const totalAdmins = dao.totalAdmins() 4 | if (totalAdmins > 0) { 5 | return 6 | } 7 | 8 | const admin = new Admin() 9 | admin.email = "admin@localhost" 10 | admin.password = [...Array(30)].map(() => (Math.random() +1).toString(36)[2]).join('') 11 | 12 | return dao.saveAdmin(admin) 13 | }, (db) => { 14 | const dao = new Dao(db) 15 | const admin = dao.findAdminByEmail("admin@localhost") 16 | if(!admin) { 17 | return 18 | } 19 | return dao.deleteAdmin(admin) 20 | }) 21 | -------------------------------------------------------------------------------- /src/backend/migrations/1713168223_mirror_scanner_ids.js: -------------------------------------------------------------------------------- 1 | /// 2 | /* Change all api_token items to match their config (i.e. scanners) id 3 | This is done so that callers will easily find api_token matching scanner id 4 | */ 5 | migrate((db) => { 6 | const dao = new Dao(db) 7 | records = dao.findRecordsByFilter("api_tokens", "config!=null") 8 | records.forEach(record => { 9 | const configId = record.get("config") 10 | db.newQuery(`UPDATE api_tokens SET id = '${configId}' WHERE id = '${record.id}'`) 11 | .execute() 12 | }); 13 | return 14 | }, (db) => { 15 | return 16 | }) 17 | -------------------------------------------------------------------------------- /src/backend/migrations/1713175897_updated_scanners.js: -------------------------------------------------------------------------------- 1 | /// 2 | migrate((db) => { 3 | const dao = new Dao(db) 4 | const collection = dao.findCollectionByNameOrId("4mw0oi15o9akrgh") 5 | 6 | // add apiToken 7 | collection.schema.addField(new SchemaField({ 8 | "system": false, 9 | "id": "wivrctle", 10 | "name": "apiToken", 11 | "type": "text", 12 | "required": false, 13 | "presentable": false, 14 | "unique": false, 15 | "options": { 16 | "min": null, 17 | "max": null, 18 | "pattern": "" 19 | } 20 | })) 21 | 22 | // add useCloudApi 23 | collection.schema.addField(new SchemaField({ 24 | "system": false, 25 | "id": "ngmdde6i", 26 | "name": "useCloudApi", 27 | "type": "bool", 28 | "required": false, 29 | "presentable": false, 30 | "unique": false, 31 | "options": {} 32 | })) 33 | 34 | // add isCloudManaged 35 | collection.schema.addField(new SchemaField({ 36 | "system": false, 37 | "id": "5jtbirvi", 38 | "name": "isCloudManaged", 39 | "type": "bool", 40 | "required": false, 41 | "presentable": false, 42 | "unique": false, 43 | "options": {} 44 | })) 45 | 46 | return dao.saveCollection(collection) 47 | }, (db) => { 48 | const dao = new Dao(db) 49 | const collection = dao.findCollectionByNameOrId("4mw0oi15o9akrgh") 50 | 51 | // remove 52 | collection.schema.removeField("wivrctle") 53 | collection.schema.removeField("5jtbirvi") 54 | collection.schema.removeField("ngmdde6i") 55 | 56 | return dao.saveCollection(collection) 57 | }) 58 | -------------------------------------------------------------------------------- /src/backend/migrations/1713175898_updated_scanners_rule.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("4mw0oi15o9akrgh") 4 | 5 | // when isCloudManaged is set, admin users cannot modify isCloudManaged field. 6 | collection.updateRule = "(@request.data.isCloudManaged = null || @request.data.isCloudManaged = isCloudManaged) && @request.auth.role = 'admin'" 7 | collection.createRule = "@request.auth.role = 'admin'" 8 | collection.deleteRule = "(@request.data.isCloudManaged = null || @request.data.isCloudManaged = isCloudManaged) && @request.auth.role = 'admin'" 9 | 10 | return dao.saveCollection(collection) 11 | }, (db) => { 12 | const dao = new Dao(db) 13 | const collection = dao.findCollectionByNameOrId("4mw0oi15o9akrgh") 14 | 15 | collection.updateRule = "@request.auth.role = 'admin'" 16 | collection.createRule = null 17 | collection.deleteRule = null 18 | 19 | return dao.saveCollection(collection) 20 | }) 21 | -------------------------------------------------------------------------------- /src/backend/migrations/1714641094_add_thumbnails_to_screenshots.js: -------------------------------------------------------------------------------- 1 | migrate((db) => { 2 | const dao = new Dao(db) 3 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 4 | 5 | // update 6 | collection.schema.addField(new SchemaField({ 7 | "system": false, 8 | "id": "dkhxu2yf", 9 | "name": "screenshots", 10 | "type": "file", 11 | "required": false, 12 | "presentable": false, 13 | "unique": false, 14 | "options": { 15 | "mimeTypes": [ 16 | "image/png" 17 | ], 18 | "thumbs": [ 19 | "96x0", 20 | "256x0", 21 | "960x0" 22 | ], 23 | "maxSelect": 99, 24 | "maxSize": 5242880, 25 | "protected": true 26 | } 27 | })) 28 | 29 | return dao.saveCollection(collection) 30 | }, (db) => { 31 | const dao = new Dao(db) 32 | const collection = dao.findCollectionByNameOrId("xeb56gcfepjsjb4") 33 | 34 | // update 35 | collection.schema.addField(new SchemaField({ 36 | "system": false, 37 | "id": "dkhxu2yf", 38 | "name": "screenshots", 39 | "type": "file", 40 | "required": false, 41 | "presentable": false, 42 | "unique": false, 43 | "options": { 44 | "mimeTypes": [ 45 | "image/png" 46 | ], 47 | "thumbs": [], 48 | "maxSelect": 99, 49 | "maxSize": 5242880, 50 | "protected": true 51 | } 52 | })) 53 | 54 | return dao.saveCollection(collection) 55 | }) 56 | -------------------------------------------------------------------------------- /src/backend/src/webhood/adminapiroutes.go: -------------------------------------------------------------------------------- 1 | package webhood 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v5" 7 | "github.com/pocketbase/pocketbase/apis" 8 | "github.com/pocketbase/pocketbase/core" 9 | ) 10 | 11 | func WebhoodAdminApiMiddleware(app core.App) []echo.MiddlewareFunc { 12 | return []echo.MiddlewareFunc{ 13 | apis.ActivityLogger(app), 14 | apis.RequireRecordAuth("users"), 15 | RequireCustomRoleAuth("admin"), 16 | } 17 | } 18 | func CreateScannerTokenRoute(app core.App) echo.Route { 19 | return echo.Route{ 20 | Method: http.MethodPost, 21 | Path: "/api/beta/admin/scanner/:id/token", 22 | Handler: func(c echo.Context) error { 23 | recordId := c.PathParam("id") 24 | dao := app.Dao() 25 | authRecord, fetchErr := dao.FindRecordById("api_tokens", recordId) 26 | if fetchErr != nil { 27 | return apis.NewNotFoundError("could not find scanner", nil) 28 | } 29 | token, tokenFetchErr := NewScannerAuthToken(app, authRecord) 30 | if tokenFetchErr != nil { 31 | println("Error creating token: " + tokenFetchErr.Error()) 32 | return apis.NewApiError(http.StatusInternalServerError, "could not create token", nil) 33 | } 34 | return c.JSON(http.StatusOK, map[string]string{"token": token}) 35 | }, 36 | Middlewares: WebhoodAdminApiMiddleware(app), 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/backend/src/webhood/config.go: -------------------------------------------------------------------------------- 1 | package webhood 2 | 3 | const ( 4 | ScannerAuthTokenValidDuration = 60 * 60 * 24 * 365 // 1 year 5 | ) 6 | -------------------------------------------------------------------------------- /src/backend/src/webhood/middleware.go: -------------------------------------------------------------------------------- 1 | package webhood 2 | 3 | import ( 4 | "github.com/labstack/echo/v5" 5 | "github.com/pocketbase/pocketbase/apis" 6 | "github.com/pocketbase/pocketbase/core" 7 | "github.com/pocketbase/pocketbase/models" 8 | ) 9 | 10 | func RequireCustomRoleAuth(roleName string) echo.MiddlewareFunc { 11 | return func(next echo.HandlerFunc) echo.HandlerFunc { 12 | return func(c echo.Context) error { 13 | // Allow admin to pass 14 | admin, _ := c.Get("admin").(*models.Admin) 15 | if admin != nil { 16 | return next(c) 17 | } 18 | // Else check for the role 19 | record, _ := c.Get("authRecord").(*models.Record) 20 | if record == nil { 21 | return apis.NewUnauthorizedError("The request requires valid authorization token to be set.", nil) 22 | } 23 | if record.Get("role").(string) != roleName { 24 | return apis.NewUnauthorizedError("The request requires valid role authorization token to be set.", nil) 25 | } 26 | 27 | return next(c) 28 | } 29 | } 30 | } 31 | 32 | func LoadScanRecordContext(app core.App) echo.MiddlewareFunc { 33 | return func(next echo.HandlerFunc) echo.HandlerFunc { 34 | return func(c echo.Context) error { 35 | recordId := c.PathParam("id") 36 | if recordId == "" { 37 | return apis.NewNotFoundError("", nil) 38 | } 39 | record, fetchErr := app.Dao().FindRecordById("scans", recordId) 40 | if fetchErr != nil { 41 | return fetchErr 42 | } 43 | c.Set("scanRecord", record) 44 | return next(c) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/core/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git 8 | cypress/ 9 | .env* 10 | __test__/ 11 | .vscode 12 | .idea 13 | .github -------------------------------------------------------------------------------- /src/core/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /src/core/.env.development: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_URL=http://127.0.0.1:8090 2 | -------------------------------------------------------------------------------- /src/core/.env.example: -------------------------------------------------------------------------------- 1 | JWT_SECRET= 2 | NEXT_PUBLIC_ANON_KEY= 3 | SERVICE_ROLE_KEY= 4 | NEXT_PUBLIC_API_URL=http://localhost:3000 5 | -------------------------------------------------------------------------------- /src/core/.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .cache 3 | public 4 | node_modules 5 | *.esm.js 6 | -------------------------------------------------------------------------------- /src/core/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "next/babel", 8 | "plugin:tailwindcss/recommended" 9 | ], 10 | "plugins": ["tailwindcss"], 11 | "rules": { 12 | "@next/next/no-html-link-for-pages": "off", 13 | "react/jsx-key": "off", 14 | "tailwindcss/no-custom-classname": "off", 15 | "react-hooks/exhaustive-deps": "off", 16 | "eslint-plugin-import/no-unassigned-import": "warn" 17 | }, 18 | "settings": { 19 | "tailwindcss": { 20 | "callees": ["cn"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/core/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | ### Cypress Custom .gitignore ### 4 | *.log.* 5 | dist-* 6 | build 7 | .history 8 | .publish 9 | _test-output 10 | cypress.zip 11 | .babel-cache 12 | cypress/screenshots 13 | cypress/videos 14 | cypress.env.json 15 | 16 | # dependencies 17 | node_modules 18 | .pnp 19 | .pnp.js 20 | 21 | # testing 22 | coverage 23 | 24 | # next.js 25 | .next/ 26 | out/ 27 | build 28 | 29 | # misc 30 | .DS_Store 31 | *.pem 32 | 33 | # debug 34 | npm-debug.log* 35 | yarn-debug.log* 36 | yarn-error.log* 37 | .pnpm-debug.log* 38 | 39 | # local env files 40 | .env.local 41 | .env.development.local 42 | .env.test.local 43 | .env.production.local 44 | 45 | # turbo 46 | .turbo 47 | 48 | .contentlayer 49 | .env 50 | .env.*.local 51 | local/ 52 | .vscode/ -------------------------------------------------------------------------------- /src/core/.prettierignore: -------------------------------------------------------------------------------- 1 | cache 2 | .cache 3 | package.json 4 | package-lock.json 5 | public 6 | CHANGELOG.md 7 | .yarn 8 | dist 9 | node_modules 10 | .next 11 | build 12 | .contentlayer -------------------------------------------------------------------------------- /src/core/.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/github" 9 | ] 10 | } -------------------------------------------------------------------------------- /src/core/Dockerfile: -------------------------------------------------------------------------------- 1 | # Source: https://raw.githubusercontent.com/vercel/next.js/canary/examples/with-docker/Dockerfile 2 | FROM node:slim AS base 3 | 4 | # Install dependencies only when needed 5 | FROM base AS deps 6 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 7 | # RUN apt-get update && apt-get install -y libc6-compat 8 | WORKDIR /app 9 | 10 | # Install dependencies based on the preferred package manager 11 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 12 | RUN \ 13 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 14 | elif [ -f package-lock.json ]; then npm ci; \ 15 | elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \ 16 | else echo "Lockfile not found." && exit 1; \ 17 | fi 18 | 19 | 20 | # Rebuild the source code only when needed 21 | FROM base AS builder 22 | WORKDIR /app 23 | COPY --from=deps /app/node_modules ./node_modules 24 | COPY . . 25 | 26 | # Next.js collects completely anonymous telemetry data about general usage. 27 | # Learn more here: https://nextjs.org/telemetry 28 | # Uncomment the following line in case you want to disable telemetry during the build. 29 | ENV NEXT_TELEMETRY_DISABLED 1 30 | 31 | # Set arguments for setting env variables to be replaced 32 | ARG NEXT_PUBLIC_API_URL=http://API_URL_VALUE 33 | ARG NEXT_PUBLIC_ANON_KEY=ANON_KEY_VALUE 34 | ARG NEXT_PUBLIC_SELF_REGISTER=SELF_REGISTER_VALUE 35 | ENV NEXT_PUBLIC_API_URL $NEXT_PUBLIC_API_URL 36 | ENV NEXT_PUBLIC_ANON_KEY $NEXT_PUBLIC_ANON_KEY 37 | ENV NEXT_PUBLIC_SELF_REGISTER $NEXT_PUBLIC_SELF_REGISTER 38 | 39 | RUN npm run build 40 | 41 | # Production image, copy all the files and run next 42 | FROM base AS runner 43 | WORKDIR /app 44 | 45 | ENV NODE_ENV production 46 | # Uncomment the following line in case you want to disable telemetry during runtime. 47 | ENV NEXT_TELEMETRY_DISABLED 1 48 | 49 | RUN addgroup --system --gid 1001 nodejs 50 | RUN adduser --system --uid 1001 nextjs 51 | 52 | COPY --from=builder /app/public ./public 53 | 54 | # Automatically leverage output traces to reduce image size 55 | # https://nextjs.org/docs/advanced-features/output-file-tracing 56 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 57 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 58 | # https://github.com/vercel/next.js/issues/48077 59 | COPY --from=builder /app/node_modules/next/dist/compiled/jest-worker ./node_modules/next/dist/compiled/jest-worker 60 | COPY entrypoint.sh ./ 61 | RUN chmod +x entrypoint.sh 62 | 63 | USER nextjs 64 | 65 | EXPOSE 3000 66 | 67 | ENV PORT 3000 68 | 69 | ENTRYPOINT ["./entrypoint.sh"] 70 | CMD ["node", "server.js"] 71 | -------------------------------------------------------------------------------- /src/core/LICENSE: -------------------------------------------------------------------------------- 1 | (c) Copyright 2023 Markus Lehtonen, all rights reserved. 2 | -------------------------------------------------------------------------------- /src/core/app/client-layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ThemeProvider } from "next-themes" 4 | 5 | import { FileTokenProvider } from "@/lib/FileTokenProvider" 6 | import { Layout } from "@/components/layout" 7 | import { Toaster } from "@/components/ui/toaster" 8 | import { TooltipProvider } from "@/components/ui/tooltip" 9 | 10 | export default function ClientLayout({ children }) { 11 | return ( 12 | 13 | 14 | 15 | {children} 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/core/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css" 2 | 3 | import { Metadata } from "next" 4 | 5 | import ClientLayout from "./client-layout" 6 | import Styled from "./style" 7 | 8 | export const metadata: Metadata = { 9 | referrer: "no-referrer", 10 | } 11 | 12 | export default function RootLayout({ children }) { 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | 20 | {children} 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/core/app/scan/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata, ResolvingMetadata } from "next" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import ScanDetails from "./scandetails" 5 | 6 | type Props = { 7 | params: { id: string } 8 | } 9 | 10 | export async function generateMetadata( 11 | { params }: Props, 12 | parent: ResolvingMetadata 13 | ): Promise { 14 | // read route params 15 | const id = params.id 16 | return { 17 | title: `${id} - ${siteConfig.name}`, 18 | } 19 | } 20 | export default async function ScanPage({ params }: { params: { id: string } }) { 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /src/core/app/scan/[id]/tabs/CodeViewer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | import { useFile, useToken } from "@/hooks/use-file" 5 | import Editor from "@monaco-editor/react" 6 | import { ScansResponse } from "@webhood/types" 7 | import { useTheme } from "next-themes" 8 | 9 | import { Icons } from "@/components/icons" 10 | 11 | //#region CodeViewer 12 | export function CodeViewer({ scanItem }: { scanItem: ScansResponse }) { 13 | const { resolvedTheme } = useTheme() 14 | const [html, setHtml] = useState("") 15 | const { token } = useToken() 16 | const htmlUrl = useFile(scanItem, "html", token) 17 | useEffect(() => { 18 | if (scanItem?.id && htmlUrl) { 19 | // fetch the html file using fetch 20 | fetch(htmlUrl) 21 | .then((res) => res.text()) 22 | .then((html) => setHtml(html)) 23 | } 24 | }, [scanItem?.id, htmlUrl]) 25 | return ( 26 | html && ( 27 | 33 | Loading... 34 | 35 | 36 | } 37 | options={{ readOnly: true, links: false }} 38 | defaultValue={html} 39 | /> 40 | ) 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/core/app/scan/[id]/tabs/ScanDetails.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ScansResponse } from "@webhood/types" 4 | 5 | import { columns } from "./datatable/columns" 6 | import { DataTable } from "./datatable/data-table" 7 | 8 | //#region ScanDetails 9 | export function ScanDetails({ scanItem }: { scanItem: ScansResponse }) { 10 | const valsGen = (item) => { 11 | if (!scanItem.scandata || !scanItem.scandata[item]) return [] 12 | const val = scanItem.scandata[item] 13 | return Object.keys(val).map((key) => { 14 | return { key: `${item}.${key}`, value: val[key] } 15 | }) 16 | } 17 | if (!scanItem.scandata) return

No details available

18 | const data = [ 19 | ...valsGen("request"), 20 | ...valsGen("response"), 21 | ...valsGen("document"), 22 | ] 23 | return ( 24 |
25 | 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /src/core/app/scan/[id]/tabs/Screenshot.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ScansResponse } from "@webhood/types" 4 | 5 | import { Icons } from "@/components/icons" 6 | import { ImageFileComponent } from "@/components/ImageFileComponent" 7 | import { ScreenshotContextMenu } from "@/components/ScreenshotActionMenu" 8 | 9 | export interface ScanImageProps { 10 | scanItem: ScansResponse 11 | } 12 | 13 | //#region ScanImage 14 | export function Screenshot({ scanItem }: ScanImageProps) { 15 | const fileName = scanItem.screenshots[0] 16 | return ( 17 |
18 | {scanItem && fileName ? ( 19 | 20 | 29 | 30 | ) : ( 31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | )} 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /src/core/app/scan/[id]/tabs/datatable/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Pin, PinOff } from "lucide-react" 4 | 5 | import { DataItem, DataItemValueOnly } from "@/components/DataItem" 6 | import { Button } from "@/components/ui/button" 7 | 8 | export const columns = [ 9 | { 10 | id: "pin", 11 | header: () => "Pin", 12 | // https://github.com/TanStack/table/discussions/3192#discussioncomment-8419949 13 | meta: { 14 | size: "44px", 15 | }, 16 | cell: ({ row }) => 17 | row.getIsPinned() ? ( 18 | 21 | ) : ( 22 |
23 | 30 |
31 | ), 32 | }, 33 | { 34 | accessorKey: "key", 35 | header: "Key", 36 | meta: { 37 | size: "165px", 38 | }, 39 | }, 40 | { 41 | accessorKey: "value", 42 | header: "Value", 43 | enableColumnFilter: false, 44 | cell: ({ row }) => { 45 | if (Array.isArray(row.original.value)) { 46 | if (row.original.value.length === 0) 47 | return 48 | return row.original.value.map((v, i) => ( 49 |
50 | 53 |
54 | )) 55 | } 56 | if (typeof row.original.value === "object" && row.original.value !== null) 57 | return Object.keys(row.original.value).map((k, i) => ( 58 |
59 | 60 |
61 | )) 62 | return 63 | }, 64 | }, 65 | ] 66 | -------------------------------------------------------------------------------- /src/core/app/scan/[id]/tabs/datatable/data-table.cy.jsx: -------------------------------------------------------------------------------- 1 | import { columns } from "./columns" 2 | import { DataTable } from "./data-table" 3 | 4 | const data = [ 5 | { 6 | key: "key", 7 | value: "stringvalue", 8 | }, 9 | { 10 | key: "key", 11 | value: ["arrayvalue1", "arrayvalue2"], 12 | }, 13 | { 14 | key: "key", 15 | value: 123, 16 | }, 17 | { 18 | key: "key", 19 | value: { 20 | key: "nestedobjectvalue", 21 | anotherkey: ["nestedarrayvalue1", "nestedarrayvalue2"], 22 | }, 23 | }, 24 | ] 25 | 26 | describe("Datatable", () => { 27 | it("renders", () => { 28 | cy.mount() 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/core/app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next" 2 | import { redirect } from "next/navigation" 3 | 4 | import { siteConfig } from "@/config/site" 5 | import { Title } from "@/components/title" 6 | import Search from "./search" 7 | 8 | export async function generateMetadata(): Promise { 9 | return { 10 | title: `Search - ${siteConfig.name}`, 11 | } 12 | } 13 | export default async function SearchPage({ searchParams }) { 14 | if ( 15 | !searchParams || 16 | searchParams.filter === undefined || 17 | !searchParams.page === undefined 18 | ) { 19 | return redirect("/search?filter=&page=1") 20 | } 21 | return ( 22 |
23 |
24 | 25 | </div> 26 | <div className="grid"> 27 | <Search /> 28 | </div> 29 | </div> 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/core/app/style.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Inter as FontSans } from "next/font/google" 4 | 5 | const fontSans = FontSans({ 6 | subsets: ["latin"], 7 | variable: "--font-sans", 8 | display: "swap", 9 | }) 10 | 11 | export default function Styled() { 12 | return ( 13 | <style jsx global>{` 14 | :root { 15 | --font-sans: ${fontSans.style.fontFamily}; 16 | } 17 | }`}</style> 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/core/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /src/core/components/ActionDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardRefExoticComponent } from "react" 2 | import { EllipsisVertical, LucideProps } from "lucide-react" 3 | 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu" 10 | 11 | export default function ActionDropdown({ children }) { 12 | return ( 13 | <DropdownMenu> 14 | <DropdownMenuTrigger> 15 | <EllipsisVertical /> 16 | </DropdownMenuTrigger> 17 | <DropdownMenuContent>{children}</DropdownMenuContent> 18 | </DropdownMenu> 19 | ) 20 | } 21 | 22 | export type ActionDropdownItemType = { 23 | label: string 24 | icon: ForwardRefExoticComponent<LucideProps> 25 | } 26 | 27 | export function ActionDropdownItem({ 28 | item, 29 | onSelect, 30 | disabled, 31 | children, 32 | }: { 33 | item: ActionDropdownItemType 34 | onSelect?: () => void 35 | disabled?: boolean 36 | children?: React.ReactElement 37 | }) { 38 | return ( 39 | <DropdownMenuItem onSelect={onSelect} disabled={disabled}> 40 | <item.icon className="mr-2 h-4 w-4" /> 41 | <span>{item.label}</span> 42 | {children} 43 | </DropdownMenuItem> 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /src/core/components/DataItem.cy.jsx: -------------------------------------------------------------------------------- 1 | import { DataItem } from './DataItem'; 2 | 3 | describe('DataItem', () => { 4 | beforeEach(() => { 5 | cy.mount( 6 | <DataItem 7 | label='test-label' 8 | content='test-content' 9 | copy={true} 10 | /> 11 | ); 12 | }); 13 | it('renders', () => { 14 | cy.get('[data-cy=dataitem-wrapper]').should('exist'); 15 | }); 16 | it("copies content to clipboard", () => { 17 | cy.get('[data-cy=dataitem-wrapper]').click(); 18 | cy.get('[data-cy=dataitem-copymessage]').should('exist'); 19 | }); 20 | it("does not show copy message when copy is false", () => { 21 | cy.mount( 22 | <DataItem 23 | label='test' 24 | content='test' 25 | copy={false} 26 | /> 27 | ); 28 | cy.get('[data-cy=dataitem-wrapper]').click(); 29 | cy.get('[data-cy=dataitem-copymessage]').should('not.exist'); 30 | }); 31 | it("clipboard contains content", () => { 32 | cy.get('[data-cy=dataitem-wrapper]').realClick(); // real world click required for clipboard 33 | cy.window().then((win) => { 34 | win.navigator.clipboard.readText().then((text) => { 35 | expect(text).to.eq('test-content'); 36 | }); 37 | }); 38 | }); 39 | it("does not show copy message after 2 second", () => { 40 | cy.get('[data-cy=dataitem-wrapper]').click(); 41 | cy.wait(2000); 42 | cy.get('[data-cy=dataitem-copymessage]').should('not.exist'); 43 | }); 44 | 45 | }); -------------------------------------------------------------------------------- /src/core/components/DataItem.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useCopyToClipboard } from "@/hooks/use-copyclipboard" 4 | 5 | import { Icons } from "@/components/icons" 6 | import { Label } from "@/components/ui/label" 7 | import { DataItemContextMenu } from "./DataItemActionMenu" 8 | 9 | function Content(props: { 10 | copied: boolean 11 | onClick: () => void 12 | content: string 13 | }) { 14 | return ( 15 | <div 16 | className="relative col-span-4 select-all text-sm font-medium" 17 | onClick={props.onClick} 18 | > 19 | <div className="flex flex-row justify-between"> 20 | <div className="max-w-5/6 truncate" title={props.content}> 21 | {props.content === null || props.content === undefined ? ( 22 | <i>Empty</i> 23 | ) : ( 24 | <DataItemContextMenu content={props.content}> 25 | {props.content} 26 | </DataItemContextMenu> 27 | )} 28 | </div> 29 | <div> 30 | {props.copied && ( 31 | <div 32 | className="mx-1 flex flex-row items-center" 33 | data-cy="dataitem-copymessage" 34 | > 35 | <p>Copied to clipboard</p> 36 | <Icons.check className="h-4" /> 37 | </div> 38 | )} 39 | </div> 40 | </div> 41 | </div> 42 | ) 43 | } 44 | 45 | export function DataItem(props: { 46 | label: string 47 | content?: string 48 | copy?: boolean 49 | }) { 50 | const { copied, setCopied, onClick } = useCopyToClipboard({ 51 | copy: true, 52 | content: props.content, 53 | }) 54 | return ( 55 | <div 56 | className="grid grid-cols-5 items-center gap-4" 57 | data-cy="dataitem-wrapper" 58 | > 59 | <Label className="truncate" title={props.label}> 60 | {props.label} 61 | </Label> 62 | <Content content={props.content} copied={copied} onClick={onClick} /> 63 | </div> 64 | ) 65 | } 66 | 67 | export function DataItemValueOnly(props: { content: string }) { 68 | const { copied, setCopied, onClick } = useCopyToClipboard({ 69 | copy: true, 70 | content: props.content, 71 | }) 72 | return ( 73 | <div className="grid grid-cols-1 rounded-md border-2 border-solid p-2"> 74 | <DataItemContextMenu content={props.content}> 75 | <Content content={props.content} copied={copied} onClick={onClick} /> 76 | </DataItemContextMenu> 77 | </div> 78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /src/core/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Component, ErrorInfo, ReactNode } from "react" 4 | 5 | interface Props { 6 | children?: ReactNode 7 | } 8 | 9 | interface State { 10 | hasError: boolean 11 | } 12 | 13 | class ErrorBoundary extends Component<Props, State> { 14 | public state: State = { 15 | hasError: false, 16 | } 17 | 18 | public static getDerivedStateFromError(_: Error): State { 19 | // Update state so the next render will show the fallback UI. 20 | return { hasError: true } 21 | } 22 | 23 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 24 | console.error("Uncaught error:", error, errorInfo) 25 | } 26 | 27 | public render() { 28 | if (this.state.hasError) { 29 | return <span>There was an error loading this component.</span> 30 | } 31 | 32 | return this.props.children 33 | } 34 | } 35 | 36 | export default ErrorBoundary 37 | -------------------------------------------------------------------------------- /src/core/components/ImageFileComponent.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image" 2 | import { useToken } from "@/hooks/use-file" 3 | 4 | import { imageLoader } from "@/lib/utils" 5 | 6 | export function ImageFileComponent({ fileName, document, alt, ...props }) { 7 | const { token } = useToken() 8 | return ( 9 | <Image 10 | src={fileName} 11 | alt={alt} 12 | loader={(props) => imageLoader(props, document, token)} 13 | {...props} 14 | /> 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/core/components/LogoutButton.cy.jsx: -------------------------------------------------------------------------------- 1 | import { LogoutButtonComponent } from "./LogoutButton" 2 | 3 | describe("<LogoutButton />", () => { 4 | it("renders", () => { 5 | // see: https://on.cypress.io/mounting-react 6 | cy.mount(<LogoutButtonComponent />) 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /src/core/components/LogoutButton.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/navigation" 2 | import { LogOut } from "lucide-react" 3 | 4 | import { pb } from "@/lib/pocketbase" 5 | import { Button } from "./ui/button" 6 | 7 | export function LogoutButton() { 8 | const router = useRouter() 9 | return ( 10 | <LogoutButtonComponent 11 | onClick={() => { 12 | pb.authStore.clear() 13 | router.push("/login") 14 | }} 15 | /> 16 | ) 17 | } 18 | 19 | export function LogoutButtonComponent({ onClick }: { onClick: () => void }) { 20 | return ( 21 | <Button variant="ghost" size="sm" onClick={onClick}> 22 | <LogOut className="dark:scale-100 dark:text-slate-400 dark:hover:text-slate-100" /> 23 | </Button> 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/core/components/RefreshApiKeyDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | 3 | import { pb } from "@/lib/pocketbase" 4 | import { Button } from "@/components/ui/button" 5 | import { 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "@/components/ui/dialog" 12 | import { Input } from "@/components/ui/input" 13 | 14 | export function RefreshApiKeyDialog({ id }: { id: string }) { 15 | const [isSubmitting, setIsSubmitting] = useState<boolean>(false) 16 | const [error, setError] = useState<null | string>(null) 17 | const [token, setToken] = useState<string | null>(null) 18 | const onSubmit = async () => { 19 | setToken(null) 20 | setIsSubmitting(true) 21 | pb.send(`/api/beta/admin/scanner/${id}/token`, { 22 | method: "POST", 23 | }) 24 | .then((data) => { 25 | setToken(data.token) 26 | if (error) setError(null) 27 | }) 28 | .catch((error) => { 29 | setError(error.message || error) 30 | }) 31 | .finally(() => { 32 | setIsSubmitting(false) 33 | }) 34 | } 35 | return ( 36 | <DialogContent className="sm:max-w-[425px]"> 37 | <DialogHeader> 38 | <DialogTitle>Refresh token</DialogTitle> 39 | <DialogDescription> 40 | Refreshing a token will invalidate the current token and generate a 41 | new one. 42 | </DialogDescription> 43 | </DialogHeader> 44 | {error && ( 45 | <DialogDescription className="text-red-500"> 46 | Error: {error} 47 | </DialogDescription> 48 | )} 49 | {token && ( 50 | <DialogDescription> 51 | Here is your new token: 52 | <Input 53 | value={token} 54 | readOnly 55 | className="w-full" 56 | onClick={(e) => { 57 | e.currentTarget.select() 58 | }} 59 | /> 60 | The token will not be shown again. 61 | </DialogDescription> 62 | )} 63 | <DialogFooter> 64 | <Button 65 | type="submit" 66 | variant={"destructive"} 67 | onClick={onSubmit} 68 | disabled={isSubmitting} 69 | > 70 | Refresh token 71 | </Button> 72 | </DialogFooter> 73 | </DialogContent> 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/core/components/ScanStatus.tsx: -------------------------------------------------------------------------------- 1 | import { scanStatsFetcher } from "@/hooks/use-api" 2 | import { Loader, RefreshCcw } from "lucide-react" 3 | import useSWR from "swr" 4 | 5 | import { Button } from "./ui/button" 6 | import { TypographySubtle } from "./ui/typography/subtle" 7 | 8 | export default function ScanStatus() { 9 | const { 10 | data: scanDataSwr, 11 | error: scanErrorSwr, 12 | isValidating: isSwrLoading, 13 | mutate, 14 | } = useSWR("/api/scanStats", scanStatsFetcher, { 15 | refreshInterval: 2 * 1000, 16 | }) 17 | return ( 18 | <div> 19 | <div className="flex flex-row items-center justify-between"> 20 | <Button 21 | variant="ghost" 22 | size="sm" 23 | onClick={() => mutate()} 24 | className="-ml-2" 25 | > 26 | <span className="mr-2 text-sm font-semibold tracking-tight"> 27 | Stats 28 | </span> 29 | {isSwrLoading ? ( 30 | <Loader size={10} className="animate animate-spin" /> 31 | ) : ( 32 | <RefreshCcw size={10} /> 33 | )} 34 | </Button> 35 | </div> 36 | {scanDataSwr && 37 | scanDataSwr.map((scan) => { 38 | return ( 39 | <div key={scan.id}> 40 | <div 41 | key={scan.id} 42 | className="flex flex-row justify-between text-sm " 43 | > 44 | <TypographySubtle>{scan.status}:</TypographySubtle> 45 | <TypographySubtle>{scan.count_items}</TypographySubtle> 46 | </div> 47 | </div> 48 | ) 49 | })} 50 | </div> 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /src/core/components/ScreenshotActionMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useToken } from "@/hooks/use-file" 2 | import { ScansResponse } from "@webhood/types" 3 | 4 | import { getPbFileUrl } from "@/lib/utils" 5 | import { 6 | ContextMenu, 7 | ContextMenuContent, 8 | ContextMenuItem, 9 | ContextMenuTrigger, 10 | } from "@/components/ui/context-menu" 11 | 12 | const basicMenuItems = [ 13 | { 14 | label: "Copy Link to Image", 15 | action: copyLinkToClipboard, 16 | }, 17 | { 18 | label: "Open Image in New Tab", 19 | action: openImageInNewTab, 20 | }, 21 | ] 22 | 23 | interface ActionProps { 24 | record: ScansResponse 25 | /** Full path to the file which can be opened in a separate tab */ 26 | fileUrl: string 27 | } 28 | 29 | function copyLinkToClipboard({ fileUrl }: ActionProps) { 30 | navigator.clipboard.writeText(fileUrl) 31 | } 32 | 33 | function openImageInNewTab({ fileUrl }: ActionProps) { 34 | window.open(fileUrl, "_blank") 35 | } 36 | 37 | export function ScreenshotContextMenu({ 38 | imageUrl, 39 | record, 40 | children, 41 | }: { 42 | imageUrl: string 43 | record: ScansResponse 44 | children: React.ReactNode 45 | }) { 46 | const { token } = useToken() 47 | const fileUrl = getPbFileUrl(record, imageUrl, token) 48 | return ( 49 | <ContextMenu> 50 | <ContextMenuTrigger>{children}</ContextMenuTrigger> 51 | <ContextMenuContent> 52 | {basicMenuItems.map((item) => ( 53 | <ContextMenuItem 54 | key={item.label} 55 | onSelect={() => item.action({ record, fileUrl })} 56 | > 57 | {item.label} 58 | </ContextMenuItem> 59 | ))} 60 | </ContextMenuContent> 61 | </ContextMenu> 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/core/components/UrlForm.cy.jsx: -------------------------------------------------------------------------------- 1 | import { UrlForm } from './UrlForm'; 2 | 3 | describe('UrlFormComponent', () => { 4 | beforeEach(() => { 5 | cy.intercept('/api/collections/scanners/records*', { fixture: 'scannersresponse.json' }) 6 | cy.mount(<UrlForm/>); 7 | }); 8 | it('can input url', () => { 9 | cy.get('[data-cy=url-input]').type('www.google.com'); 10 | cy.get('[data-cy=url-input]').should('have.value', 'www.google.com'); 11 | }); 12 | it('can submit url', () => { 13 | cy.get('[data-cy=url-input]').type('www.google.com'); 14 | cy.get('[data-cy=url-submit]').click(); 15 | }); 16 | it('can submit url to pb', () => { 17 | cy.get('[data-cy=url-input]').type('www.google.com'); 18 | cy.intercept('POST', '/api/collections/scans/records', { fixture: "scanresponseitem" }).as('postWebhook'); 19 | cy.get('[data-cy=url-submit]').click(); 20 | }); 21 | it('shows error when submitting empty url', () => { 22 | cy.mount( 23 | <UrlForm/> 24 | ) 25 | cy.get('[data-cy=url-submit]').click(); 26 | cy.get('[data-cy=url-input-error]').should('exist'); 27 | }); 28 | /* This fails due to cypress not supporting react 14. TODO: reactive when cypress supports react 14 29 | it('shows scanner selector', () => { 30 | cy.get('[data-cy=options-open]').click(); 31 | cy.get("select[name='options.scannerId']").select("scanner2", {force: true}); 32 | }); 33 | */ 34 | }); -------------------------------------------------------------------------------- /src/core/components/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SiteHeader } from "@/components/site-header" 2 | 3 | interface LayoutProps { 4 | children: React.ReactNode 5 | } 6 | 7 | export function Layout({ children }: LayoutProps) { 8 | return ( 9 | <> 10 | <SiteHeader /> 11 | <main className="w-screen overflow-hidden md:pl-40">{children}</main> 12 | </> 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/core/components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "@/config/site" 2 | import { MainNav } from "@/components/main-nav" 3 | import { ThemeToggle } from "@/components/theme-toggle" 4 | import { LogoutButton } from "./LogoutButton" 5 | import ScanStatus from "./ScanStatus" 6 | 7 | export function SiteHeader() { 8 | return ( 9 | <header className="fixed top-0 h-full w-40 border-r border-r-slate-200 dark:border-r-slate-700 max-md:w-0"> 10 | <div className="flex h-full flex-col justify-between py-4"> 11 | <div className="container flex flex-col items-center space-x-4 sm:justify-between sm:space-x-0"> 12 | <MainNav items={siteConfig.mainNav} /> 13 | </div> 14 | <div> 15 | <div className="p-4 max-md:hidden"> 16 | <ScanStatus /> 17 | </div> 18 | <div className="flex items-center justify-center gap-2 max-md:hidden"> 19 | <ThemeToggle /> 20 | <LogoutButton /> 21 | </div> 22 | </div> 23 | </div> 24 | </header> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/core/components/statusMessage.cy.jsx: -------------------------------------------------------------------------------- 1 | import { StatusMessage, StatusMessageUncontrolled } from "./statusMessage"; 2 | 3 | describe("StatusMessage", () => { 4 | it("shows error message", () => { 5 | cy.mount(<StatusMessage statusMessage={{ 6 | status: "error", 7 | message: "Error test", 8 | }} />); 9 | cy.get('[data-cy=status-message]').should('have.text', 'Error test'); 10 | }); 11 | it("shows generic error message", () => { 12 | cy.mount(<StatusMessage statusMessage={{ 13 | status: "error", 14 | }} />); 15 | cy.get('[data-cy=status-message]').should('have.text', 'Error'); 16 | }); 17 | it("shows success message", () => { 18 | cy.mount(<StatusMessage statusMessage={{ 19 | status: "success", 20 | message: "Success test", 21 | }} />); 22 | cy.get('[data-cy=status-message]').should('have.text', 'Success test'); 23 | }); 24 | it("shows generic success message", () => { 25 | cy.mount(<StatusMessage statusMessage={{ 26 | status: "success", 27 | }} />); 28 | cy.get('[data-cy=status-message]').should('have.text', 'Success'); 29 | }); 30 | it("disappears after 2 seconds", () => { 31 | cy.mount(<StatusMessageUncontrolled statusMessage={{ 32 | status: "success", 33 | message: "Success test", 34 | }} />); 35 | cy.wait(2000); 36 | cy.get('[data-cy=status-message]').should('not.exist'); 37 | }); 38 | }); -------------------------------------------------------------------------------- /src/core/components/statusMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { useStatusMessage } from "@/hooks/use-statusmessage" 3 | 4 | import { Icons } from "@/components/icons" 5 | import { TypographySubtle } from "@/components/ui/typography/subtle" 6 | 7 | export interface StatusMessageProps { 8 | message: string 9 | status: "error" | "success" 10 | details?: any 11 | } 12 | 13 | export function StatusMessage({ 14 | statusMessage, 15 | }: { 16 | statusMessage: StatusMessageProps 17 | }) { 18 | if (!statusMessage) return null 19 | const defaultError = "Error" 20 | const defaultMessage = "Success" 21 | return ( 22 | <div className="flex items-center gap-2"> 23 | {statusMessage.status === "error" && ( 24 | <> 25 | <Icons.warning className="h-5 w-5 text-red-500" /> 26 | </> 27 | )} 28 | {statusMessage.status === "success" && ( 29 | <Icons.check className="h-5 w-5 text-green-500" /> 30 | )} 31 | <span data-cy="status-message"> 32 | <TypographySubtle> 33 | {statusMessage.message || 34 | (statusMessage.status === "success" 35 | ? defaultMessage 36 | : defaultError)} 37 | </TypographySubtle> 38 | </span> 39 | </div> 40 | ) 41 | } 42 | 43 | export function StatusMessageUncontrolled({ 44 | statusMessage, 45 | }: { 46 | statusMessage: StatusMessageProps 47 | }) { 48 | const { statusMessage: statusMessageControlled, setStatusMessage } = 49 | useStatusMessage() 50 | useEffect(() => { 51 | setStatusMessage(statusMessage) 52 | }, [statusMessage.message, statusMessage.status]) 53 | return <StatusMessage statusMessage={statusMessageControlled} /> 54 | } 55 | -------------------------------------------------------------------------------- /src/core/components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes" 2 | 3 | import { Icons } from "@/components/icons" 4 | import { Button } from "@/components/ui/button" 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu" 11 | 12 | export function ThemeToggle() { 13 | const { setTheme } = useTheme() 14 | 15 | return ( 16 | <DropdownMenu> 17 | <DropdownMenuTrigger asChild> 18 | <Button variant="ghost" size="sm"> 19 | <Icons.sun className="rotate-0 scale-100 transition-all hover:text-slate-900 dark:-rotate-90 dark:scale-0 dark:text-slate-400 dark:hover:text-slate-100" /> 20 | <Icons.moon className="absolute rotate-90 scale-0 transition-all hover:text-slate-900 dark:rotate-0 dark:scale-100 dark:text-slate-400 dark:hover:text-slate-100" /> 21 | <span className="sr-only">Toggle theme</span> 22 | </Button> 23 | </DropdownMenuTrigger> 24 | <DropdownMenuContent align="end" forceMount> 25 | <DropdownMenuItem onClick={() => setTheme("light")}> 26 | <Icons.sun className="mr-2 h-4 w-4" /> 27 | <span>Light</span> 28 | </DropdownMenuItem> 29 | <DropdownMenuItem onClick={() => setTheme("dark")}> 30 | <Icons.moon className="mr-2 h-4 w-4" /> 31 | <span>Dark</span> 32 | </DropdownMenuItem> 33 | <DropdownMenuItem onClick={() => setTheme("system")}> 34 | <Icons.laptop className="mr-2 h-4 w-4" /> 35 | <span>System</span> 36 | </DropdownMenuItem> 37 | </DropdownMenuContent> 38 | </DropdownMenu> 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/core/components/title.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface TitleProps { 4 | title?: string | React.ReactNode 5 | subtitle?: string | React.ReactNode 6 | } 7 | export function Title(props: TitleProps) { 8 | return ( 9 | <div className="flex max-w-[980px] flex-col items-start gap-2"> 10 | {props.title && ( 11 | <h1 className="text-3xl font-extrabold leading-tight tracking-tighter sm:text-3xl md:text-5xl lg:text-6xl"> 12 | {props.title} 13 | </h1> 14 | )} 15 | {props.subtitle && ( 16 | <p className="max-w-[700px] text-lg text-slate-700 dark:text-slate-400 sm:text-xl"> 17 | {props.subtitle} 18 | </p> 19 | )} 20 | </div> 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/core/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef<typeof AccordionPrimitive.Item>, 13 | React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> 14 | >(({ className, ...props }, ref) => ( 15 | <AccordionPrimitive.Item 16 | ref={ref} 17 | className={cn( 18 | "border-b border-b-slate-200 dark:border-b-slate-700", 19 | className 20 | )} 21 | {...props} 22 | /> 23 | )) 24 | AccordionItem.displayName = "AccordionItem" 25 | 26 | const AccordionTrigger = React.forwardRef< 27 | React.ElementRef<typeof AccordionPrimitive.Trigger>, 28 | React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> 29 | >(({ className, children, ...props }, ref) => ( 30 | <AccordionPrimitive.Header className="flex"> 31 | <AccordionPrimitive.Trigger 32 | ref={ref} 33 | className={cn( 34 | "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", 35 | className 36 | )} 37 | {...props} 38 | > 39 | {children} 40 | <ChevronDown className="h-4 w-4 transition-transform duration-200" /> 41 | </AccordionPrimitive.Trigger> 42 | </AccordionPrimitive.Header> 43 | )) 44 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 45 | 46 | const AccordionContent = React.forwardRef< 47 | React.ElementRef<typeof AccordionPrimitive.Content>, 48 | React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> 49 | >(({ className, children, ...props }, ref) => ( 50 | <AccordionPrimitive.Content 51 | ref={ref} 52 | className={cn( 53 | "overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down", 54 | className 55 | )} 56 | {...props} 57 | > 58 | <div className="pb-4 pt-0">{children}</div> 59 | </AccordionPrimitive.Content> 60 | )) 61 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 62 | 63 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 64 | -------------------------------------------------------------------------------- /src/core/components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /src/core/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef<typeof AvatarPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> 11 | >(({ className, ...props }, ref) => ( 12 | <AvatarPrimitive.Root 13 | ref={ref} 14 | className={cn( 15 | "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", 16 | className 17 | )} 18 | {...props} 19 | /> 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef<typeof AvatarPrimitive.Image>, 25 | React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> 26 | >(({ className, ...props }, ref) => ( 27 | <AvatarPrimitive.Image 28 | ref={ref} 29 | className={cn("aspect-square h-full w-full", className)} 30 | {...props} 31 | /> 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef<typeof AvatarPrimitive.Fallback>, 37 | React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> 38 | >(({ className, ...props }, ref) => ( 39 | <AvatarPrimitive.Fallback 40 | ref={ref} 41 | className={cn( 42 | "flex h-full w-full items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700", 43 | className 44 | )} 45 | {...props} 46 | /> 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /src/core/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes<HTMLDivElement>, 28 | VariantProps<typeof badgeVariants> {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 | <div className={cn(badgeVariants({ variant }), className)} {...props} /> 33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/core/components/ui/button-icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useState } from "react" 3 | import { cva, VariantProps } from "class-variance-authority" 4 | 5 | import { cn, copyToClipboard } from "@/lib/utils" 6 | import { Icons } from "@/components/icons" 7 | import { Button, ButtonProps, buttonVariants } from "@/components/ui/button" 8 | 9 | export interface IconButtonProps extends ButtonProps { 10 | isLoading?: boolean 11 | icon: React.ReactNode 12 | children?: React.ReactNode 13 | } 14 | 15 | const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>( 16 | ({ className, variant, size, isLoading, icon, children, ...props }, ref) => { 17 | return ( 18 | <button 19 | className={cn(buttonVariants({ variant, size, className }))} 20 | ref={ref} 21 | disabled={isLoading} 22 | {...props} 23 | > 24 | <div className={"flex flex-row items-center gap-1"}> 25 | {children} 26 | {isLoading ? ( 27 | <Icons.loader className="h-4" /> 28 | ) : ( 29 | <div className="h-4">{icon}</div> 30 | )} 31 | </div> 32 | </button> 33 | ) 34 | } 35 | ) 36 | IconButton.displayName = "IconButton" 37 | 38 | export { IconButton } 39 | -------------------------------------------------------------------------------- /src/core/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "active:scale-95 inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 dark:hover:bg-slate-800 dark:hover:text-slate-100 disabled:opacity-50 dark:focus:ring-slate-400 disabled:pointer-events-none dark:focus:ring-offset-slate-900 data-[state=open]:bg-slate-100 dark:data-[state=open]:bg-slate-800", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-50 dark:text-slate-900", 13 | destructive: 14 | "bg-red-500 text-white hover:bg-red-600 dark:hover:bg-red-600", 15 | outline: 16 | "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-100", 17 | subtle: 18 | "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-100", 19 | ghost: 20 | "bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800 dark:text-slate-100 dark:hover:text-slate-100 data-[state=open]:bg-transparent dark:data-[state=open]:bg-transparent", 21 | link: "bg-transparent dark:bg-transparent underline-offset-4 hover:underline text-slate-900 dark:text-slate-100 hover:bg-transparent dark:hover:bg-transparent", 22 | }, 23 | size: { 24 | default: "h-10 py-2 px-4", 25 | sm: "h-9 px-2 rounded-md", 26 | lg: "h-11 px-8 rounded-md", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes<HTMLButtonElement>, 38 | VariantProps<typeof buttonVariants> {} 39 | 40 | const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 41 | ({ className, variant, size, ...props }, ref) => { 42 | return ( 43 | <button 44 | className={cn(buttonVariants({ variant, size, className }))} 45 | ref={ref} 46 | {...props} 47 | /> 48 | ) 49 | } 50 | ) 51 | Button.displayName = "Button" 52 | 53 | export { Button, buttonVariants } 54 | -------------------------------------------------------------------------------- /src/core/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef<typeof CheckboxPrimitive.Root>, 11 | React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> 12 | >(({ className, ...props }, ref) => ( 13 | <CheckboxPrimitive.Root 14 | ref={ref} 15 | className={cn( 16 | "peer h-4 w-4 shrink-0 rounded-sm border border-slate-300 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900", 17 | className 18 | )} 19 | {...props} 20 | > 21 | <CheckboxPrimitive.Indicator 22 | className={cn("flex items-center justify-center")} 23 | > 24 | <Check className="h-4 w-4" /> 25 | </CheckboxPrimitive.Indicator> 26 | </CheckboxPrimitive.Root> 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /src/core/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /src/core/components/ui/container.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface ContainerProps { 6 | className?: string | undefined 7 | children?: React.ReactNode 8 | } 9 | 10 | // TODO: Add a ref to this component, don't know how to add 11 | const Container = React.forwardRef<ContainerProps>( 12 | ({ className, children, ...props }: ContainerProps, ref) => { 13 | return ( 14 | <div 15 | className={cn( 16 | "container grid auto-rows-max gap-6 pb-8 pt-6 md:py-10", 17 | className 18 | )} 19 | {...props} 20 | > 21 | {children} 22 | </div> 23 | ) 24 | } 25 | ) 26 | Container.displayName = "Container" 27 | 28 | export { Container } 29 | -------------------------------------------------------------------------------- /src/core/components/ui/copy-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { useState } from "react" 3 | import { cva, VariantProps } from "class-variance-authority" 4 | 5 | import { cn, copyToClipboard } from "@/lib/utils" 6 | import { Icons } from "@/components/icons" 7 | import { Button, ButtonProps, buttonVariants } from "@/components/ui/button" 8 | 9 | export interface CopyButtonProps extends ButtonProps {} 10 | 11 | const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>( 12 | ({ className, variant, size, ...props }, ref) => { 13 | const [isCopied, setIsCopied] = useState(false) 14 | const onClick = (value) => { 15 | setIsCopied(true) 16 | copyToClipboard(props.value as string) 17 | setTimeout(() => setIsCopied(false), 1000) 18 | } 19 | return ( 20 | <button 21 | className={cn(buttonVariants({ variant, size, className }))} 22 | onClick={onClick} 23 | ref={ref} 24 | {...props} 25 | > 26 | {isCopied ? ( 27 | <Icons.check className="h-4 w-4" /> 28 | ) : ( 29 | <Icons.copy className="h-4 w-4" /> 30 | )} 31 | </button> 32 | ) 33 | } 34 | ) 35 | CopyButton.displayName = "CopyButton" 36 | 37 | export { CopyButton } 38 | -------------------------------------------------------------------------------- /src/core/components/ui/generic-tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/icons" 2 | import { 3 | Tooltip, 4 | TooltipContent, 5 | TooltipProvider, 6 | TooltipTrigger, 7 | } from "@/components/ui/tooltip" 8 | 9 | export function GenericTooltip({ children }: { children: React.ReactNode }) { 10 | return ( 11 | <Tooltip> 12 | <TooltipTrigger type="button"> 13 | <Icons.info className="h-4 w-4 text-slate-500" /> 14 | </TooltipTrigger> 15 | <TooltipContent>{children}</TooltipContent> 16 | </Tooltip> 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/core/components/ui/hover-card.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const HoverCard = HoverCardPrimitive.Root 9 | 10 | const HoverCardTrigger = HoverCardPrimitive.Trigger 11 | 12 | const HoverCardContent = React.forwardRef< 13 | React.ElementRef<typeof HoverCardPrimitive.Content>, 14 | React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | <HoverCardPrimitive.Content 17 | ref={ref} 18 | align={align} 19 | sideOffset={sideOffset} 20 | className={cn( 21 | "z-50 w-64 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in zoom-in-90 dark:border-slate-800 dark:bg-slate-800", 22 | className 23 | )} 24 | {...props} 25 | /> 26 | )) 27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName 28 | 29 | export { HoverCard, HoverCardTrigger, HoverCardContent } 30 | -------------------------------------------------------------------------------- /src/core/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes<HTMLInputElement> {} 7 | 8 | const Input = React.forwardRef<HTMLInputElement, InputProps>( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 | <input 12 | className={cn( 13 | "flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900", 14 | className 15 | )} 16 | ref={ref} 17 | {...props} 18 | /> 19 | ) 20 | } 21 | ) 22 | Input.displayName = "Input" 23 | 24 | export { Input } 25 | -------------------------------------------------------------------------------- /src/core/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Label = React.forwardRef< 9 | React.ElementRef<typeof LabelPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> 11 | >(({ className, ...props }, ref) => ( 12 | <LabelPrimitive.Root 13 | ref={ref} 14 | className={cn( 15 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", 16 | className 17 | )} 18 | {...props} 19 | /> 20 | )) 21 | Label.displayName = LabelPrimitive.Root.displayName 22 | 23 | export { Label } 24 | -------------------------------------------------------------------------------- /src/core/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef<typeof PopoverPrimitive.Content>, 14 | React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | <PopoverPrimitive.Portal> 17 | <PopoverPrimitive.Content 18 | ref={ref} 19 | align={align} 20 | sideOffset={sideOffset} 21 | className={cn( 22 | "z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-slate-800 dark:bg-slate-800", 23 | className 24 | )} 25 | {...props} 26 | /> 27 | </PopoverPrimitive.Portal> 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /src/core/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ProgressPrimitive from "@radix-ui/react-progress" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef<typeof ProgressPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> 11 | >(({ className, value, ...props }, ref) => ( 12 | <ProgressPrimitive.Root 13 | ref={ref} 14 | className={cn( 15 | "relative h-4 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800", 16 | className 17 | )} 18 | {...props} 19 | > 20 | <ProgressPrimitive.Indicator 21 | className="h-full w-full flex-1 bg-slate-900 transition-all dark:bg-slate-400" 22 | style={{ transform: `translateX(-${100 - (value || 0)}%)` }} 23 | /> 24 | </ProgressPrimitive.Root> 25 | )) 26 | Progress.displayName = ProgressPrimitive.Root.displayName 27 | 28 | export { Progress } 29 | -------------------------------------------------------------------------------- /src/core/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 5 | import { Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const RadioGroup = React.forwardRef< 10 | React.ElementRef<typeof RadioGroupPrimitive.Root>, 11 | React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> 12 | >(({ className, ...props }, ref) => { 13 | return ( 14 | <RadioGroupPrimitive.Root 15 | className={cn("grid gap-2", className)} 16 | {...props} 17 | ref={ref} 18 | /> 19 | ) 20 | }) 21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName 22 | 23 | const RadioGroupItem = React.forwardRef< 24 | React.ElementRef<typeof RadioGroupPrimitive.Item>, 25 | React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> 26 | >(({ className, children, ...props }, ref) => { 27 | return ( 28 | <RadioGroupPrimitive.Item 29 | ref={ref} 30 | className={cn( 31 | "text:fill-slate-50 h-4 w-4 rounded-full border border-slate-300 text-slate-900 hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-100 dark:hover:text-slate-900 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900", 32 | className 33 | )} 34 | {...props} 35 | > 36 | <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> 37 | <Circle className="h-2.5 w-2.5 fill-slate-900 dark:fill-slate-50" /> 38 | </RadioGroupPrimitive.Indicator> 39 | </RadioGroupPrimitive.Item> 40 | ) 41 | }) 42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName 43 | 44 | export { RadioGroup, RadioGroupItem } 45 | -------------------------------------------------------------------------------- /src/core/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef<typeof ScrollAreaPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> 11 | >(({ className, children, ...props }, ref) => ( 12 | <ScrollAreaPrimitive.Root 13 | ref={ref} 14 | className={cn("relative overflow-hidden", className)} 15 | {...props} 16 | > 17 | <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> 18 | {children} 19 | </ScrollAreaPrimitive.Viewport> 20 | <ScrollBar /> 21 | <ScrollAreaPrimitive.Corner /> 22 | </ScrollAreaPrimitive.Root> 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, 28 | React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | <ScrollAreaPrimitive.ScrollAreaScrollbar 31 | ref={ref} 32 | orientation={orientation} 33 | className={cn( 34 | "flex touch-none select-none transition-colors", 35 | orientation === "vertical" && 36 | "h-full w-2.5 border-l border-l-transparent p-[1px]", 37 | orientation === "horizontal" && 38 | "h-2.5 border-t border-t-transparent p-[1px]", 39 | className 40 | )} 41 | {...props} 42 | > 43 | <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-slate-300 dark:bg-slate-700" /> 44 | </ScrollAreaPrimitive.ScrollAreaScrollbar> 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /src/core/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef<typeof SeparatorPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | <SeparatorPrimitive.Root 17 | ref={ref} 18 | decorative={decorative} 19 | orientation={orientation} 20 | className={cn( 21 | "bg-slate-200 dark:bg-slate-700", 22 | orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", 23 | className 24 | )} 25 | {...props} 26 | /> 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/core/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SliderPrimitive from "@radix-ui/react-slider" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Slider = React.forwardRef< 9 | React.ElementRef<typeof SliderPrimitive.Root>, 10 | React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> 11 | >(({ className, ...props }, ref) => ( 12 | <SliderPrimitive.Root 13 | ref={ref} 14 | className={cn( 15 | "relative flex w-full touch-none select-none items-center", 16 | className 17 | )} 18 | {...props} 19 | > 20 | <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"> 21 | <SliderPrimitive.Range className="absolute h-full bg-slate-900 dark:bg-slate-400" /> 22 | </SliderPrimitive.Track> 23 | <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-slate-900 bg-white transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:border-slate-100 dark:bg-slate-400 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900" /> 24 | </SliderPrimitive.Root> 25 | )) 26 | Slider.displayName = SliderPrimitive.Root.displayName 27 | 28 | export { Slider } 29 | -------------------------------------------------------------------------------- /src/core/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef<typeof SwitchPrimitives.Root>, 10 | React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> 11 | >(({ className, ...props }, ref) => ( 12 | <SwitchPrimitives.Root 13 | className={cn( 14 | "peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-slate-900 data-[state=unchecked]:bg-slate-200 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900 dark:data-[state=checked]:bg-slate-400 dark:data-[state=unchecked]:bg-slate-700", 15 | className 16 | )} 17 | {...props} 18 | ref={ref} 19 | > 20 | <SwitchPrimitives.Thumb 21 | className={cn( 22 | "pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" 23 | )} 24 | /> 25 | </SwitchPrimitives.Root> 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /src/core/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef<typeof TabsPrimitive.List>, 12 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> 13 | >(({ className, ...props }, ref) => ( 14 | <TabsPrimitive.List 15 | ref={ref} 16 | className={cn( 17 | "inline-flex items-center justify-center rounded-md bg-slate-100 p-1 dark:bg-slate-800", 18 | className 19 | )} 20 | {...props} 21 | /> 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef<typeof TabsPrimitive.Trigger>, 27 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> 28 | >(({ className, ...props }, ref) => ( 29 | <TabsPrimitive.Trigger 30 | className={cn( 31 | "inline-flex min-w-[100px] items-center justify-center rounded-[0.185rem] px-3 py-1.5 text-sm font-medium text-slate-700 transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-slate-900 data-[state=active]:shadow-sm dark:text-slate-200 dark:data-[state=active]:bg-slate-900 dark:data-[state=active]:text-slate-100", 32 | className 33 | )} 34 | {...props} 35 | ref={ref} 36 | /> 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef<typeof TabsPrimitive.Content>, 42 | React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> 43 | >(({ className, ...props }, ref) => ( 44 | <TabsPrimitive.Content 45 | className={cn( 46 | "mt-2 rounded-md border border-slate-200 p-6 dark:border-slate-700", 47 | className 48 | )} 49 | {...props} 50 | ref={ref} 51 | /> 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /src/core/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} 7 | 8 | const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 | <textarea 12 | className={cn( 13 | "flex h-20 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:text-slate-50 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900", 14 | className 15 | )} 16 | ref={ref} 17 | {...props} 18 | /> 19 | ) 20 | } 21 | ) 22 | Textarea.displayName = "Textarea" 23 | 24 | export { Textarea } 25 | -------------------------------------------------------------------------------- /src/core/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | 5 | import { 6 | Toast, 7 | ToastClose, 8 | ToastDescription, 9 | ToastProvider, 10 | ToastTitle, 11 | ToastViewport, 12 | } from "@/components/ui/toast" 13 | 14 | export function Toaster() { 15 | const { toasts } = useToast() 16 | 17 | return ( 18 | <ToastProvider> 19 | {toasts.map(function ({ id, title, description, action, ...props }) { 20 | return ( 21 | <Toast key={id} {...props}> 22 | <div className="grid gap-1"> 23 | {title && <ToastTitle>{title}</ToastTitle>} 24 | {description && ( 25 | <ToastDescription>{description}</ToastDescription> 26 | )} 27 | </div> 28 | {action} 29 | <ToastClose /> 30 | </Toast> 31 | ) 32 | })} 33 | <ToastViewport /> 34 | </ToastProvider> 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/core/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TogglePrimitive from "@radix-ui/react-toggle" 5 | import { cva, VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const toggleVariants = cva( 10 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors data-[state=on]:bg-slate-200 dark:hover:bg-slate-800 dark:data-[state=on]:bg-slate-700 focus:outline-none dark:text-slate-100 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:focus:ring-offset-slate-900 hover:bg-slate-100 dark:hover:text-slate-100 dark:data-[state=on]:text-slate-100", 11 | { 12 | variants: { 13 | variant: { 14 | default: "bg-transparent", 15 | outline: 16 | "bg-transparent border border-slate-200 hover:bg-slate-100 dark:border-slate-700", 17 | }, 18 | size: { 19 | default: "h-10 px-3", 20 | sm: "h-9 px-2.5", 21 | lg: "h-11 px-5", 22 | }, 23 | }, 24 | defaultVariants: { 25 | variant: "default", 26 | size: "default", 27 | }, 28 | } 29 | ) 30 | 31 | const Toggle = React.forwardRef< 32 | React.ElementRef<typeof TogglePrimitive.Root>, 33 | React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & 34 | VariantProps<typeof toggleVariants> 35 | >(({ className, variant, size, ...props }, ref) => ( 36 | <TogglePrimitive.Root 37 | ref={ref} 38 | className={cn(toggleVariants({ variant, size, className }))} 39 | {...props} 40 | /> 41 | )) 42 | 43 | Toggle.displayName = TogglePrimitive.Root.displayName 44 | 45 | export { Toggle, toggleVariants } 46 | -------------------------------------------------------------------------------- /src/core/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = ({ ...props }) => <TooltipPrimitive.Root {...props} /> 11 | Tooltip.displayName = TooltipPrimitive.Tooltip.displayName 12 | 13 | const TooltipTrigger = TooltipPrimitive.Trigger 14 | 15 | const TooltipContent = React.forwardRef< 16 | React.ElementRef<typeof TooltipPrimitive.Content>, 17 | React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> 18 | >(({ className, sideOffset = 4, ...props }, ref) => ( 19 | <TooltipPrimitive.Content 20 | ref={ref} 21 | sideOffset={sideOffset} 22 | className={cn( 23 | "z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400", 24 | className 25 | )} 26 | {...props} 27 | /> 28 | )) 29 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 30 | 31 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 32 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/blockquote.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyBlockquote({ 2 | children, 3 | }: { 4 | children: React.ReactNode 5 | }) { 6 | return ( 7 | <blockquote className="mt-6 border-l-2 border-slate-300 pl-6 italic text-slate-800 dark:border-slate-600 dark:text-slate-200"> 8 | {children} 9 | </blockquote> 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/h1.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyH1() { 2 | return ( 3 | <h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl"> 4 | Taxing Laughter: The Joke Tax Chronicles 5 | </h1> 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/h2.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyH2({ children }) { 2 | return ( 3 | <h2 className="mt-10 scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700"> 4 | {children} 5 | </h2> 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/h3.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyH3({ children }) { 2 | return ( 3 | <h3 className="mt-8 scroll-m-20 text-2xl font-semibold tracking-tight"> 4 | {children} 5 | </h3> 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/h4.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyH4({ children }) { 2 | return ( 3 | <h4 className="my-4 scroll-m-20 text-xl font-semibold tracking-tight"> 4 | {children} 5 | </h4> 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/inline-code.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyInlineCode() { 2 | return ( 3 | <code className="relative rounded bg-slate-100 px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold text-slate-900 dark:bg-slate-800 dark:text-slate-400"> 4 | @radix-ui/react-alert-dialog 5 | </code> 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/large.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyLarge({ children }: { children: React.ReactNode }) { 2 | return ( 3 | <div className="text-lg font-semibold text-slate-900 dark:text-slate-50"> 4 | {children} 5 | </div> 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/lead.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyLead() { 2 | return ( 3 | <p className="text-xl text-slate-700 dark:text-slate-400"> 4 | A modal dialog that interrupts the user with important content and expects 5 | a response. 6 | </p> 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/list.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyList() { 2 | return ( 3 | <ul className="my-6 ml-6 list-disc [&>li]:mt-2"> 4 | <li>1st level of puns: 5 gold coins</li> 5 | <li>2nd level of jokes: 10 gold coins</li> 6 | <li>3rd level of one-liners : 20 gold coins</li> 7 | </ul> 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/p.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyP({ children }) { 2 | return <p className="leading-7 [&:not(:first-child)]:mt-6">{children}</p> 3 | } 4 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/small.tsx: -------------------------------------------------------------------------------- 1 | export function TypographySmall() { 2 | return ( 3 | <small className="text-sm font-medium leading-none">Email address</small> 4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/subtle.tsx: -------------------------------------------------------------------------------- 1 | export function TypographySubtle({ children }) { 2 | return ( 3 | <p className="text-sm text-slate-500 dark:text-slate-500">{children}</p> 4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /src/core/components/ui/typography/table.tsx: -------------------------------------------------------------------------------- 1 | export function TypographyTable() { 2 | return ( 3 | <div className="my-6 w-full overflow-y-auto"> 4 | <table className="w-full"> 5 | <thead> 6 | <tr className="m-0 border-t border-slate-300 p-0 even:bg-slate-100 dark:border-slate-700 dark:even:bg-slate-800"> 7 | <th className="border border-slate-200 px-4 py-2 text-left font-bold dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"></th> 8 | <th className="border border-slate-200 px-4 py-2 text-left font-bold dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"></th> 9 | </tr> 10 | </thead> 11 | <tbody> 12 | <tr className="m-0 border-t border-slate-200 p-0 even:bg-slate-100 dark:border-slate-700 dark:even:bg-slate-800"> 13 | <td className="border border-slate-200 px-4 py-2 text-left dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"> 14 | Empty 15 | </td> 16 | <td className="border border-slate-200 px-4 py-2 text-left dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"> 17 | Overflowing 18 | </td> 19 | </tr> 20 | <tr className="m-0 border-t border-slate-200 p-0 even:bg-slate-100 dark:border-slate-700 dark:even:bg-slate-800"> 21 | <td className="border border-slate-200 px-4 py-2 text-left dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"> 22 | Modest 23 | </td> 24 | <td className="border border-slate-200 px-4 py-2 text-left dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"> 25 | Satisfied 26 | </td> 27 | </tr> 28 | <tr className="m-0 border-t border-slate-200 p-0 even:bg-slate-100 dark:border-slate-600 dark:even:bg-slate-800"> 29 | <td className="border border-slate-200 px-4 py-2 text-left dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"> 30 | Full 31 | </td> 32 | <td className="border border-slate-200 px-4 py-2 text-left dark:border-slate-700 [&[align=center]]:text-center [&[align=right]]:text-right"> 33 | Ecstatic 34 | </td> 35 | </tr> 36 | </tbody> 37 | </table> 38 | </div> 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/core/config/site.ts: -------------------------------------------------------------------------------- 1 | import * as process from "process" 2 | 3 | import { NavItem } from "@/types/nav" 4 | 5 | interface SiteConfig { 6 | name: string 7 | description: string 8 | mainNav: NavItem[] 9 | selfRegistration: string 10 | traceVersions: string[] // currently supported traceversions 11 | } 12 | 13 | export const siteConfig: SiteConfig = { 14 | name: "Webhood", 15 | description: 16 | "Modern, simple and private URL scanner that helps you analyze website and find if they are safe to visit by you and your organization's users.", 17 | mainNav: [ 18 | { 19 | title: "Home", 20 | href: "/", 21 | }, 22 | { 23 | title: "Search", 24 | href: "/search?filter=&page=1", 25 | path: "/search", 26 | }, 27 | { 28 | title: "Account", 29 | href: "/account", 30 | }, 31 | { 32 | title: "Settings", 33 | href: "/settings", 34 | roleRequired: "admin", 35 | }, 36 | ], 37 | // @ts-ignore 38 | selfRegistration: process.env.NEXT_PUBLIC_SELF_REGISTER, 39 | traceVersions: ["0.1"], 40 | } 41 | -------------------------------------------------------------------------------- /src/core/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress" 2 | 3 | export default defineConfig({ 4 | projectId: "robbtp", 5 | e2e: { 6 | baseUrl: "http://localhost:3000", 7 | env: { 8 | // SCANNER_TOKEN: "", 9 | }, 10 | setupNodeEvents(on, config) { 11 | // implement node event listeners here 12 | }, 13 | }, 14 | 15 | component: { 16 | devServer: { 17 | framework: "next", 18 | bundler: "webpack", 19 | }, 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/core/cypress/e2e/api-tests/scan.cy.js: -------------------------------------------------------------------------------- 1 | describe('get scans', () => { 2 | beforeEach(() => { 3 | cy.request({ 4 | 'url': 'localhost:8090/api/beta/scans/', 5 | 'headers': { 6 | 'Authorization': `Bearer ${Cypress.env('SCANNER_TOKEN')}` 7 | } 8 | }).as('scanRequest'); 9 | cy.request({ 10 | 'url': 'localhost:8090/api/beta/scans/', 11 | failOnStatusCode: false 12 | }).as('unAuthscanRequest'); 13 | }); 14 | it('posts new scan - POST', () => { 15 | cy.request({ 16 | 'method': 'POST', 17 | 'url': 'localhost:8090/api/beta/scans', 18 | 'headers': { 19 | 'Authorization': `Bearer ${Cypress.env('SCANNER_TOKEN')}` 20 | }, 21 | 'body': { 22 | 'url': 'https://www.google.com', 23 | } 24 | }).as('scanPost'); 25 | cy.get('@scanPost').then(scans => { 26 | expect(scans.status).to.eq(202); 27 | expect(scans.body).to.have.property('status', 'pending'); 28 | // expect location header to /api/beta/scans/{id} 29 | expect(scans.headers).to.have.property('location', `/api/beta/scans/${scans.body.id}`); 30 | expect(scans.body).to.have.property('url', 'https://www.google.com'); 31 | }); 32 | }); 33 | it('fetches scan items - GET', () => { 34 | cy.get('@scanRequest').then(scans => { 35 | console.log(scans.body) 36 | // status is 200, when scan querying multiple scans 37 | expect(scans.status).to.eq(200); 38 | // expect todoItem[0] to have property 'completed' equal to false 39 | expect(scans.body[0]).to.have.property('id'); 40 | expect(scans.body[0]).to.have.property('status', 'pending'); 41 | expect(scans.body[0]).to.have.property('url', 'https://www.google.com'); 42 | }); 43 | }); 44 | it('fails when request is not authorized', () => { 45 | cy.get('@unAuthscanRequest').then(scans => { 46 | expect(scans.status).to.eq(401); 47 | }); 48 | }); 49 | }); -------------------------------------------------------------------------------- /src/core/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /src/core/cypress/fixtures/scannersresponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "perPage": 500, 4 | "totalItems": -1, 5 | "totalPages": -1, 6 | "items": [ 7 | { 8 | "collectionId": "4mw0oi15o9akrgh", 9 | "collectionName": "scanners", 10 | "config": { 11 | "lang": "", 12 | "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36" 13 | }, 14 | "created": "2023-09-24 10:07:26.067Z", 15 | "id": "0lk9lksaydebgvx", 16 | "name": "scanner1", 17 | "updated": "2024-01-04 08:30:04.456Z" 18 | }, 19 | { 20 | "collectionId": "4mw0oi15o9akrgh", 21 | "collectionName": "scanners", 22 | "config": { 23 | "lang": "fi-FI", 24 | "ua": "" 25 | }, 26 | "created": "2024-01-03 08:32:39.693Z", 27 | "id": "xednszzs3tqj5bf", 28 | "name": "scanner2", 29 | "updated": "2024-01-04 08:34:58.047Z" 30 | }, 31 | { 32 | "collectionId": "4mw0oi15o9akrgh", 33 | "collectionName": "scanners", 34 | "config": {}, 35 | "created": "2024-01-03 10:06:44.735Z", 36 | "id": "nixwylyzfz4mqb4", 37 | "name": "scanner3", 38 | "updated": "2024-01-03 11:42:44.377Z" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /src/core/cypress/fixtures/scanresponseitem.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectionId": "xeb56gcfepjsjb4", 3 | "collectionName": "scans", 4 | "created": "2023-05-02 19:45:43.467Z", 5 | "done_at": "", 6 | "error": "", 7 | "final_url": "", 8 | "html": [], 9 | "id": "wqff5tgcsuyfa9f", 10 | "screenshots": [], 11 | "slug": "hs.fi-1683056743461", 12 | "status": "pending", 13 | "updated": "2023-05-02 19:45:43.467Z", 14 | "url": "https://hs.fi" 15 | } -------------------------------------------------------------------------------- /src/core/cypress/fixtures/token.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE2ODMwNTgwMDQsImlkIjoiODRpZDZpYXl1OG1zd3UwIiwidHlwZSI6ImF1dGhSZWNvcmQifQ._8Ag9ixrx6amz6AkfGNthkZMwaO1AjZKBByOQcgkpRs" 3 | } -------------------------------------------------------------------------------- /src/core/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | ///<reference path="../global.d.ts" /> 3 | /// <reference types="cypress" /> 4 | // *********************************************** 5 | // This example commands.ts shows you how to 6 | // create various custom commands and overwrite 7 | // existing commands. 8 | // 9 | // For more comprehensive examples of custom 10 | // commands please read more here: 11 | // https://on.cypress.io/custom-commands 12 | // *********************************************** 13 | // 14 | // 15 | // -- This is a parent command -- 16 | // Cypress.Commands.add('login', (email, password) => { ... }) 17 | // 18 | // 19 | // -- This is a child command -- 20 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 21 | // 22 | // 23 | // -- This is a dual command -- 24 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 25 | // 26 | // 27 | // -- This will overwrite an existing command -- 28 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 29 | // 30 | // declare global { 31 | // namespace Cypress { 32 | // interface Chainable { 33 | // login(email: string, password: string): Chainable<void> 34 | // drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element> 35 | // dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element> 36 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element> 37 | // } 38 | // } 39 | // } 40 | // @ts-ignore 41 | Cypress.Commands.add("LoginWithPageSession", (uName: string, pwd: string) => { 42 | cy.session([uName, pwd], () => { 43 | cy.visit("http://localhost:3000/") 44 | // wait for #email for 10 seconds 45 | cy.get("#email", { timeout: 10000 }).should("be.visible") 46 | cy.get("#email").type(uName) 47 | cy.get("#password").type(pwd) 48 | cy.get("form").submit() 49 | cy.get("h1", { timeout: 10000 }) 50 | .contains("Start scans") 51 | .should("be.visible") 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/core/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta charset="utf-8"> 5 | <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 | <meta name="viewport" content="width=device-width,initial-scale=1.0"> 7 | <title>Components App 8 | 9 |
10 | 11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /src/core/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands" 18 | import "styles/globals.css" 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | 23 | import { mount } from "cypress/react18" 24 | 25 | import "cypress-real-events" 26 | 27 | // Augment the Cypress namespace to include type definitions for 28 | // your custom command. 29 | // Alternatively, can be defined in cypress/support/component.d.ts 30 | // with a at the top of your spec. 31 | declare global { 32 | namespace Cypress { 33 | interface Chainable { 34 | mount: typeof mount 35 | } 36 | } 37 | } 38 | 39 | Cypress.Commands.add("mount", mount) 40 | 41 | // Example use: 42 | // cy.mount() 43 | -------------------------------------------------------------------------------- /src/core/cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // *********************************************************** 3 | // This example support/e2e.ts is processed and 4 | // loaded automatically before your test files. 5 | // 6 | // This is a great place to put global configuration and 7 | // behavior that modifies Cypress. 8 | // 9 | // You can change the location of this file or turn off 10 | // automatically serving support files with the 11 | // 'supportFile' configuration option. 12 | // 13 | // You can read more here: 14 | // https://on.cypress.io/configuration 15 | // *********************************************************** 16 | 17 | // Import commands.js using ES2015 syntax: 18 | import "./commands" 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | -------------------------------------------------------------------------------- /src/core/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["./**/*.ts", "../cypress.d.ts", "e2e/api-tests/scan.cy.js"], 4 | "exclude": [], 5 | "compilerOptions": { 6 | "types": ["cypress", "@percy/cypress"], 7 | "lib": ["es2015", "dom"], 8 | "isolatedModules": false, 9 | "allowJs": true, 10 | "noEmit": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/core/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | 4 | services: 5 | core: 6 | container_name: webhood-core 7 | build: . 8 | restart: always 9 | env_file: 10 | - .env.docker.local 11 | ports: 12 | - "3003:3000" -------------------------------------------------------------------------------- /src/core/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # The purpose of this script is to replace the values of the environment variables 4 | # in the Nextjs build files with the actual values of the environment variables 5 | # at runtime. This is necessary because Nextjs does not support environment variables 6 | # in the build files. 7 | 8 | echo "Check that we have keys in vars" 9 | test -n "$API_URL" 10 | test -n "$SELF_REGISTER" 11 | 12 | echo "API_URL: \t $API_URL" 13 | echo "SELF_REGISTER: \t $SELF_REGISTER" 14 | # Replace the values in the Nextjs build files with the actual values of the environment variables 15 | # We are using dummy values in the build files to make it easier to find and replace them 16 | # with the actual values of the environment variables. 17 | # 18 | # The dummy values are: 19 | # - API_URL_VALUE 20 | # - SELF_REGISTER_VALUE 21 | # 22 | # if API_URL is not set, we will use the default value "/" 23 | if [ -z "$API_URL" ]; then 24 | API_URL="/" 25 | fi 26 | find /app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#http://API_URL_VALUE#$API_URL#g" 27 | find /app/.next \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i "s#SELF_REGISTER_VALUE#$SELF_REGISTER#g" 28 | 29 | echo "Starting Webhood" 30 | exec "$@" 31 | -------------------------------------------------------------------------------- /src/core/hooks/use-copyclipboard.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | import { copyToClipboard } from "@/lib/utils" 4 | 5 | export function useCopyToClipboard(props: { copy: boolean; content: string }) { 6 | const [copied, setCopied] = useState(false) 7 | useEffect(() => { 8 | // reset copied state after 2 seconds 9 | if (copied) { 10 | setTimeout(() => { 11 | setCopied(false) 12 | }, 2000) 13 | } 14 | }, [copied]) 15 | const onClick = () => { 16 | if (props.copy) { 17 | copyToClipboard(props.content) 18 | setCopied(true) 19 | } 20 | } 21 | return { copied, setCopied, onClick } 22 | } 23 | -------------------------------------------------------------------------------- /src/core/hooks/use-file.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react" 2 | import { ScansRecord } from "@webhood/types" 3 | 4 | import { FileTokenContext } from "@/lib/FileTokenProvider" 5 | import { pb } from "@/lib/pocketbase" 6 | 7 | export function useFile( 8 | document: ScansRecord, 9 | fileField: string, 10 | token: string, 11 | fileNumber?: number 12 | ) { 13 | const [fileUrl, setFileUrl] = useState(undefined) 14 | useEffect(() => { 15 | if (!document || !fileField || !token || document.status !== "done") return 16 | const url = pb.files.getUrl( 17 | document, 18 | document[fileField][fileNumber || 0], 19 | { 20 | token: token, 21 | } 22 | ) 23 | setFileUrl(url) 24 | }, [document, fileField, token]) 25 | if (!document[fileField] || document[fileField].length === 0) return undefined 26 | return fileUrl 27 | } 28 | 29 | export function useFile2(scanItem) { 30 | const [html, setHtml] = useState("") 31 | const { token } = useToken() 32 | const htmlUrl = useFile(scanItem, "html", token, 1) 33 | useEffect(() => { 34 | if (scanItem?.id && htmlUrl) { 35 | // fetch the html file using fetch 36 | fetch(htmlUrl) 37 | .then((res) => res.text()) 38 | .then((html) => setHtml(html)) 39 | } 40 | }, [scanItem?.id, htmlUrl]) 41 | return { html } 42 | } 43 | 44 | export function useToken() { 45 | const { token, setToken, isLoading, setIsLoading } = 46 | useContext(FileTokenContext) 47 | 48 | useEffect(() => { 49 | if (!token) updateToken() 50 | setInterval(() => { 51 | updateToken() 52 | }, 60000) // token expires currently in two minutes, we update it every 60 seconds to be sure 53 | }, []) 54 | 55 | const updateToken = async () => { 56 | if (isLoading) return 57 | setIsLoading(true) 58 | const newToken = await pb.files 59 | .getToken({ $autoCancel: false }) 60 | .catch(() => null) 61 | setToken(newToken) 62 | setIsLoading(false) 63 | } 64 | 65 | return { token, setToken, updateToken } 66 | } 67 | -------------------------------------------------------------------------------- /src/core/hooks/use-statusmessage.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | 5 | import { StatusMessageProps } from "@/components/statusMessage" 6 | 7 | export function useStatusMessage() { 8 | const [statusMessage, setStatusMessage] = useState< 9 | StatusMessageProps | undefined 10 | >(undefined) 11 | useEffect(() => { 12 | if (!statusMessage) return 13 | if (statusMessage.status === "success") { 14 | setTimeout(() => { 15 | setStatusMessage(undefined) 16 | }, 2000) 17 | } 18 | }, [statusMessage?.status]) 19 | return { statusMessage, setStatusMessage } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/hooks/use-sub.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | 3 | import { pb } from "@/lib/pocketbase" 4 | 5 | /** 6 | * Subscribe to changes in a collection using Pocketbase 7 | * @param collection - the collection to subscribe to, e.g. "scans" 8 | * @param selector - the selector to use, e.g. "scanId" 9 | * @param update - the function to call when the collection changes 10 | * @returns void 11 | * @example 12 | * ```tsx 13 | * useSubscription("scans", scanItem?.id, () => mutate({ slug: scanId })) 14 | * ``` 15 | * @see {@link https://pocketbase.io/docs/api-realtime} 16 | */ 17 | export function useSubscription( 18 | collection: string, 19 | selector: string, 20 | update: () => void 21 | ) { 22 | useEffect(() => { 23 | // subscribe to changes in scan. on return, cleanup 24 | if (!selector) return 25 | const prom = pb.collection(collection).subscribe(selector, async () => { 26 | update() 27 | }) 28 | return () => { 29 | prom 30 | .then((sub) => { 31 | sub() 32 | }) 33 | .catch((e) => { 34 | console.error("error unsubscribing", e) 35 | }) 36 | } 37 | }, [selector]) 38 | } 39 | -------------------------------------------------------------------------------- /src/core/jest.config.ts: -------------------------------------------------------------------------------- 1 | // jest.config.mjs 2 | import nextJest from "next/jest.js" 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: "./", 7 | }) 8 | 9 | // Add any custom config to be passed to Jest 10 | /** @type {import('jest').Config} */ 11 | const config = { 12 | // Add more setup options before each test is run 13 | // setupFilesAfterEnv: ['/jest.setup.js'], 14 | setupFilesAfterEnv: ["@testing-library/jest-dom"], // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work 15 | moduleDirectories: ["node_modules", "/"], 16 | testEnvironment: "jest-environment-jsdom", 17 | modulePathIgnorePatterns: ["cypress"], 18 | } 19 | 20 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 21 | export default createJestConfig(config) 22 | -------------------------------------------------------------------------------- /src/core/lib/FileTokenProvider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useState } from "react" 2 | 3 | export const FileTokenContext = createContext({ 4 | token: null, 5 | setToken: null, 6 | isLoading: false, 7 | setIsLoading: null, 8 | }) 9 | 10 | export function FileTokenProvider({ children }) { 11 | const [token, setToken] = useState(null) 12 | const [isLoading, setIsLoading] = useState(false) 13 | 14 | return ( 15 | 18 | {children} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/core/lib/api_error.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | 3 | interface CatchErrorsFromInterface { 4 | (req: NextApiRequest, res: NextApiResponse): Promise 5 | } 6 | 7 | export function catchErrorsFrom(handler: CatchErrorsFromInterface) { 8 | return async (req: NextApiRequest, res: NextApiResponse) => { 9 | return handler(req, res).catch((error) => { 10 | console.error(error) 11 | return res 12 | .status(error.statusCode || 500) 13 | .json({ error: error.message || error.toString() }) 14 | }) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/core/lib/pocketbase.ts: -------------------------------------------------------------------------------- 1 | import PocketBase from "pocketbase" 2 | 3 | export const pb = new PocketBase(process.env.NEXT_PUBLIC_API_URL) 4 | -------------------------------------------------------------------------------- /src/core/lib/tips.ts: -------------------------------------------------------------------------------- 1 | const ScannerUaTip = 2 | "User agent used during the scan " + 3 | "(e.g. Mozilla/5.0 (Windows NT 10.0; Win64; x64) " 4 | const ScannerLangTip = 5 | "Set language of the browser" + " (e.g. en-US, zh-CN, ja-JP, etc.)" 6 | const SimultaneousScansTooltip = 7 | "Maximum number of scans that can be run at the same time" 8 | const StealthTooltip = "Enable stealth mode to try to circumvent bot detection" 9 | const SkipCookiePromptTooltip = 10 | "Try to skip cookie prompts by using 'I Dont Care About Cookies' browser extension" 11 | const UseCloudApiTooltip = 12 | "Connect this scanner to Webhood Cloud API to use additional features. Get the token from cloud.webhood.io" 13 | 14 | export { 15 | ScannerLangTip, 16 | ScannerUaTip, 17 | SimultaneousScansTooltip, 18 | SkipCookiePromptTooltip, 19 | StealthTooltip, 20 | UseCloudApiTooltip, 21 | } 22 | -------------------------------------------------------------------------------- /src/core/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /src/core/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | output: "standalone", 5 | basePath: "", 6 | images: { 7 | remotePatterns: [ 8 | { 9 | protocol: 'https', 10 | hostname: '*' 11 | }, 12 | { 13 | protocol: 'http', 14 | hostname: '*' 15 | }, 16 | ], 17 | }, 18 | experimental: {}, 19 | transpilePackages: ['pocketbase'], 20 | } 21 | export default nextConfig 22 | -------------------------------------------------------------------------------- /src/core/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app" 2 | import { Inter as FontSans } from "next/font/google" 3 | import { ThemeProvider } from "next-themes" 4 | 5 | import "@/styles/globals.css" 6 | 7 | import Head from "next/head" 8 | 9 | import { FileTokenProvider } from "@/lib/FileTokenProvider" 10 | // import { client } from "@/lib/supabase" 11 | import { Icons } from "@/components/icons" 12 | import { Toaster } from "@/components/ui/toaster" 13 | import { TooltipProvider } from "@/components/ui/tooltip" 14 | 15 | const fontSans = FontSans({ 16 | subsets: ["latin"], 17 | variable: "--font-sans", 18 | display: "swap", 19 | }) 20 | 21 | export default function App({ Component, pageProps }: AppProps) { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/core/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Head, Html, Main, NextScript } from "next/document" 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /src/core/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { AccountSettings } from "@/components/accountSettings" 5 | import { Layout } from "@/components/layout" 6 | import { ScannerSettings } from "@/components/scannerSettingsCard" 7 | import { Title } from "@/components/title" 8 | import { Container } from "@/components/ui/container" 9 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 10 | 11 | export default function Settings() { 12 | return ( 13 | 14 | {/* @ts-ignore* */} 15 | 16 | 17 | Settings - {siteConfig.name} 18 | 19 | 23 | <Tabs defaultValue={"general"}> 24 | <TabsList> 25 | <TabsTrigger value="general">General</TabsTrigger> 26 | <TabsTrigger value="account">Accounts</TabsTrigger> 27 | </TabsList> 28 | <TabsContent value="general"> 29 | <ScannerSettings /> 30 | </TabsContent> 31 | <TabsContent value="account"> 32 | <AccountSettings /> 33 | </TabsContent> 34 | </Tabs> 35 | </Container> 36 | </Layout> 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /src/core/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: { config: "./tailwind.config.js" }, // or name of your tailwind config file 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/core/prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: "lf", 4 | semi: false, 5 | singleQuote: false, 6 | tabWidth: 2, 7 | trailingComma: "es5", 8 | importOrder: [ 9 | "^(react/(.*)$)|^(react$)", 10 | "^(next/(.*)$)|^(next$)", 11 | "<THIRD_PARTY_MODULES>", 12 | "", 13 | "^types$", 14 | "^@/types/(.*)$", 15 | "^@/config/(.*)$", 16 | "^@/lib/(.*)$", 17 | "^@/components/(.*)$", 18 | "^@/styles/(.*)$", 19 | "^[./]", 20 | ], 21 | importOrderSeparation: false, 22 | importOrderSortSpecifiers: true, 23 | importOrderBuiltinModulesToTop: true, 24 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 25 | importOrderMergeDuplicateImports: true, 26 | importOrderCombineTypeAndValueImports: true, 27 | plugins: [ 28 | "@ianvs/prettier-plugin-sort-imports", 29 | "prettier-plugin-tailwindcss", 30 | ], 31 | tailwindConfig: "./tailwind.config.js", 32 | } 33 | -------------------------------------------------------------------------------- /src/core/public/scan-in-progress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhood-io/webhood/174e8be4b821b7a232410907ceee49afb16330dd/src/core/public/scan-in-progress.png -------------------------------------------------------------------------------- /src/core/public/x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhood-io/webhood/174e8be4b821b7a232410907ceee49afb16330dd/src/core/public/x.png -------------------------------------------------------------------------------- /src/core/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /src/core/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | container: { 12 | center: true, 13 | padding: "2rem", 14 | screens: { 15 | "2xl": "1400px", 16 | }, 17 | }, 18 | extend: { 19 | colors: { 20 | border: "hsl(var(--border))", 21 | input: "hsl(var(--input))", 22 | ring: "hsl(var(--ring))", 23 | background: "hsl(var(--background))", 24 | foreground: "hsl(var(--foreground))", 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | destructive: { 34 | DEFAULT: "hsl(var(--destructive))", 35 | foreground: "hsl(var(--destructive-foreground))", 36 | }, 37 | muted: { 38 | DEFAULT: "hsl(var(--muted))", 39 | foreground: "hsl(var(--muted-foreground))", 40 | }, 41 | accent: { 42 | DEFAULT: "hsl(var(--accent))", 43 | foreground: "hsl(var(--accent-foreground))", 44 | }, 45 | popover: { 46 | DEFAULT: "hsl(var(--popover))", 47 | foreground: "hsl(var(--popover-foreground))", 48 | }, 49 | card: { 50 | DEFAULT: "hsl(var(--card))", 51 | foreground: "hsl(var(--card-foreground))", 52 | }, 53 | }, 54 | borderRadius: { 55 | lg: "var(--radius)", 56 | md: "calc(var(--radius) - 2px)", 57 | sm: "calc(var(--radius) - 4px)", 58 | }, 59 | keyframes: { 60 | "accordion-down": { 61 | from: { height: 0 }, 62 | to: { height: "var(--radix-accordion-content-height)" }, 63 | }, 64 | "accordion-up": { 65 | from: { height: "var(--radix-accordion-content-height)" }, 66 | to: { height: 0 }, 67 | }, 68 | }, 69 | animation: { 70 | "accordion-down": "accordion-down 0.2s ease-out", 71 | "accordion-up": "accordion-up 0.2s ease-out", 72 | }, 73 | }, 74 | }, 75 | plugins: [require("tailwindcss-animate")], 76 | } -------------------------------------------------------------------------------- /src/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": false, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": [ 23 | "./*" 24 | ] 25 | }, 26 | "plugins": [ 27 | { 28 | "name": "next" 29 | } 30 | ], 31 | "strictNullChecks": false 32 | }, 33 | "types": [ 34 | "node", 35 | "jest", 36 | "@testing-library/jest-dom" 37 | ], 38 | "include": [ 39 | "next-env.d.ts", 40 | "**/*.ts", 41 | "**/*.tsx", 42 | "jest.setup.ts", 43 | "__test__/ChangePasswordForm.test.js", 44 | "cypress/e2e/api-tests/scan.cy.js", 45 | ".next/types/**/*.ts" 46 | ], 47 | "exclude": [ 48 | // https://stackoverflow.com/questions/58999086/cypress-causing-type-errors-in-jest-assertions/72663546#72663546 49 | "./cypress.config.ts", 50 | //other exclusions that may help https://github.com/cypress-io/cypress/issues/22059#issuecomment-1428298264 51 | "node_modules", 52 | "cypress", 53 | "**/*.cy.tsx" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /src/core/types/nav.ts: -------------------------------------------------------------------------------- 1 | export interface NavItem { 2 | title: string 3 | href?: string 4 | disabled?: boolean 5 | path?: string 6 | external?: boolean 7 | roleRequired?: string 8 | } 9 | -------------------------------------------------------------------------------- /src/core/types/token.ts: -------------------------------------------------------------------------------- 1 | import { JWTPayload } from "jose" 2 | 3 | export interface ApiToken extends JWTPayload { 4 | role: string 5 | } 6 | 7 | // Used when creating a new token 8 | export interface ApiTokenPayload { 9 | role: string 10 | sub: string 11 | } 12 | 13 | export interface ApiTokenResponse { 14 | id: string 15 | token: string 16 | expires: string 17 | } 18 | -------------------------------------------------------------------------------- /src/scanner/.env.template: -------------------------------------------------------------------------------- 1 | ENDPOINT=http://127.0.0.1:8090 2 | SCANNER_TOKEN= 3 | SELF_SIGNED=true 4 | LOG_LEVEL=info 5 | SCANNER_NO_PRIVATE_IPS=true -------------------------------------------------------------------------------- /src/scanner/.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | .local/ 3 | .model.json 4 | # Logs 5 | logs 6 | tmp/ 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | .cache 110 | 111 | # Docusaurus cache and generated files 112 | .docusaurus 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v2 130 | .yarn/cache 131 | .yarn/unplugged 132 | .yarn/build-state.yml 133 | .yarn/install-state.gz 134 | .pnp.* 135 | .idea/ 136 | .DS_Store -------------------------------------------------------------------------------- /src/scanner/.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensions": ["ts"], 3 | "node-option": [ 4 | "experimental-specifier-resolution=node", 5 | "loader=ts-node/esm", 6 | "trace-warnings" 7 | ] 8 | } -------------------------------------------------------------------------------- /src/scanner/.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/github" 9 | ] 10 | } -------------------------------------------------------------------------------- /src/scanner/Dockerfile: -------------------------------------------------------------------------------- 1 | # Filename: Dockerfile 2 | FROM node:21-bullseye-slim AS base 3 | 4 | FROM node:21-bullseye-slim AS builder 5 | WORKDIR /app 6 | 7 | 8 | # Copy everything for build 9 | ADD . ./ 10 | 11 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 12 | ENV PUPPETEER_SKIP_DOWNLOAD true 13 | 14 | # Install NPM dependencies for build 15 | RUN yarn install 16 | 17 | RUN yarn run build 18 | 19 | # Switch to final container 20 | FROM base as final 21 | 22 | 23 | # We don't need the standalone Chromium 24 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 25 | ENV PUPPETEER_SKIP_DOWNLOAD true 26 | 27 | # Install Google Chrome Stable and fonts 28 | # Note: this installs the necessary libs to make the browser work with Puppeteer. 29 | RUN apt-get update && apt-get install gnupg wget ca-certificates fonts-recommended fonts-noto-cjk -y && \ 30 | wget --quiet --output-document=- https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor > /etc/apt/trusted.gpg.d/google-archive.gpg && \ 31 | sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' && \ 32 | apt-get update && \ 33 | apt-get install google-chrome-stable -y --no-install-recommends && \ 34 | rm -rf /var/lib/apt/lists/* 35 | 36 | ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser 37 | 38 | ENV NODE_ENV=production 39 | 40 | # FROM public.ecr.aws/lambda/nodejs:14.2022.09.09.11 41 | # Create working directory 42 | WORKDIR /usr/src/app 43 | 44 | # Copy the built files from build 45 | COPY --from=builder /app/dist ./dist 46 | COPY --from=builder /app/package.json /app/yarn.lock* ./ 47 | RUN mkdir -p /var/lib/kubelet/seccomp/profiles 48 | COPY ./chrome.json /var/lib/kubelet/seccomp/profiles/chrome.json 49 | 50 | RUN yarn install --production 51 | 52 | ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init 53 | RUN chmod +x /usr/local/bin/dumb-init 54 | 55 | 56 | # Copy plugin 57 | ADD extensions ./extensions 58 | 59 | RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ 60 | && mkdir -p /home/pptruser/Downloads \ 61 | && chown -R pptruser:pptruser /home/pptruser \ 62 | && chown -R pptruser:pptruser /usr/src/app 63 | 64 | USER pptruser 65 | 66 | # Expose app 67 | EXPOSE 3000 68 | # Run app 69 | ENTRYPOINT ["dumb-init", "--"] 70 | CMD ["yarn", "prod"] -------------------------------------------------------------------------------- /src/scanner/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Filename: Dockerfile 2 | FROM node:20-bullseye-slim 3 | WORKDIR /app 4 | 5 | # Copy package.json 6 | COPY package.json yarn.lock* ./ 7 | 8 | # Install NPM dependencies for function 9 | RUN yarn install 10 | 11 | ADD src ./src 12 | 13 | # We don't need the standalone Chromium 14 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 15 | 16 | ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser 17 | 18 | # Copy plugin 19 | ADD fihnjjcciajhdojfnbdddfaoknhalnja ./fihnjjcciajhdojfnbdddfaoknhalnja 20 | 21 | # Expose app 22 | EXPOSE 3000 23 | # Run app 24 | CMD ["yarn", "run", "start"] -------------------------------------------------------------------------------- /src/scanner/LICENSE: -------------------------------------------------------------------------------- 1 | (c) Copyright 2023 Markus Lehtonen, all rights reserved. -------------------------------------------------------------------------------- /src/scanner/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | node-headless: 3 | build: 4 | context: ./ 5 | dockerfile: Dockerfile.dev 6 | # use Dockerfile.dev 7 | 8 | ports: 9 | - 3030:3030 10 | restart: always 11 | environment: 12 | - ENDPOINT 13 | - SCANNER_TOKEN 14 | - SELF_SIGNED 15 | # https://stackoverflow.com/questions/50662388/running-headless-chrome-puppeteer-with-no-sandbox/53975412#53975412 16 | security_opt: 17 | - seccomp=./chrome.json 18 | -------------------------------------------------------------------------------- /src/scanner/extensions/README.md: -------------------------------------------------------------------------------- 1 | ### About 2 | 3 | This folder contains Chrome extension which can be used by the scanner depending on the configuration. 4 | 5 | ### Extensions 6 | 7 | | Name | Description | ID | 8 | | --- | --- | --- | 9 | | I don't care about cookies | This extension removes cookie warnings from almost all websites. | `fihnjjcciajhdojfnbdddfaoknhalnja` | -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2019 Daniel Kladnik 2 | 3 | This program is free software: you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation, either version 3 of the License, or 6 | (at your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, 9 | but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | GNU General Public License for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html. -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/_locales/cs/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "I don't care about cookies", 4 | "description": "Name of the extension." 5 | }, 6 | 7 | "extensionDescription": { 8 | "message": "Rozlučte se s oznámeními.", 9 | "description": "Description of the extension." 10 | }, 11 | 12 | "reportSkippedTitle": { 13 | "message": "Rozšíření je pro $HOSTNAME$ zakázáno.", 14 | "description": "Tells the user it cannot report a whitelisted domain.", 15 | "placeholders": { 16 | "hostname" : { 17 | "content" : "$1", 18 | "example" : "domain.com" 19 | } 20 | } 21 | }, 22 | 23 | "reportSkippedMessage": { 24 | "message": "Na tomto webu jste rozšíření zakázali. Povolte jej a znovu zkontrolujte správnou funkčnost rozšíření.", 25 | "description": "Tells the user it cannot report a whitelisted domain." 26 | }, 27 | 28 | "reportConfirm": { 29 | "message": "Chtele nahlásit oznámení o cookies na $HOSTNAME$?", 30 | "description": "Ask for confirmation before reporting a website. Don't use quotes here.", 31 | "placeholders": { 32 | "hostname" : { 33 | "content" : "$1", 34 | "example" : "domain.com" 35 | } 36 | } 37 | }, 38 | 39 | "menuEnable": { 40 | "message": "Povolit rozšíření pro $HOSTNAME$", 41 | "description": "Menu option.", 42 | "placeholders": { 43 | "hostname" : { 44 | "content" : "$1", 45 | "example" : "domain.com" 46 | } 47 | } 48 | }, 49 | 50 | "menuDisable": { 51 | "message": "Zakázat rozšíření pro $HOSTNAME$", 52 | "description": "Menu option.", 53 | "placeholders": { 54 | "hostname" : { 55 | "content" : "$1", 56 | "example" : "domain.com" 57 | } 58 | } 59 | }, 60 | 61 | "menuIdle": { 62 | "message": "Rozšíření na tomto webu nefunguje", 63 | "description": "Menu option visible when enabling/disabling doesn't work." 64 | }, 65 | 66 | "menuReport": { 67 | "message": "Nahlásit oznámení o cookies", 68 | "description": "Menu option." 69 | }, 70 | 71 | "menuSupport": { 72 | "message": "Podpořit tento projekt", 73 | "description": "Menu option." 74 | }, 75 | 76 | "optionsTitle": { 77 | "message": "Nastavení", 78 | "description": "Title for the options page." 79 | }, 80 | 81 | "optionsWhitelist": { 82 | "message": "Seznam všech povolených webů, jeden web na řádek:", 83 | "description": "Options text for whitelist box." 84 | }, 85 | 86 | "optionsButton": { 87 | "message": "Uložit nastavení", 88 | "description": "Options text for SAVE button." 89 | }, 90 | 91 | "optionsSaved": { 92 | "message": "Nastavení bylo uloženo.", 93 | "description": "Message after saving options." 94 | } 95 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/_locales/da/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "I don't care about cookies", 4 | "description": "Name of the extension." 5 | }, 6 | 7 | "extensionDescription": { 8 | "message": "Sig farvel til advarsler.", 9 | "description": "Description of the extension." 10 | }, 11 | 12 | "reportSkippedTitle": { 13 | "message": "Udvidelsen er slukket på $HOSTNAME$.", 14 | "description": "Tells the user it cannot report a whitelisted domain.", 15 | "placeholders": { 16 | "hostname" : { 17 | "content" : "$1", 18 | "example" : "domain.com" 19 | } 20 | } 21 | }, 22 | 23 | "reportSkippedMessage": { 24 | "message": "Du har slukket udvidelsen på dette webstedet. Vær venlig at tænde den på og se, om du ser cookie-advarslen før du rapporterer det.", 25 | "description": "Tells the user it cannot report a whitelisted domain." 26 | }, 27 | 28 | "reportConfirm": { 29 | "message": "Vil du rapportere en cookie-advarsel på $HOSTNAME$?", 30 | "description": "Ask for confirmation before reporting a website. Don't use quotes here.", 31 | "placeholders": { 32 | "hostname" : { 33 | "content" : "$1", 34 | "example" : "domain.com" 35 | } 36 | } 37 | }, 38 | 39 | "menuEnable": { 40 | "message": "Tænd udvidelsen på $HOSTNAME$", 41 | "description": "Menu option.", 42 | "placeholders": { 43 | "hostname" : { 44 | "content" : "$1", 45 | "example" : "domain.com" 46 | } 47 | } 48 | }, 49 | 50 | "menuDisable": { 51 | "message": "Sluk utvidelsen på $HOSTNAME$", 52 | "description": "Menu option.", 53 | "placeholders": { 54 | "hostname" : { 55 | "content" : "$1", 56 | "example" : "domain.com" 57 | } 58 | } 59 | }, 60 | 61 | "menuIdle": { 62 | "message": "Udvidelsen virker ikke på dette webstedet", 63 | "description": "Menu option visible when enabling/disabling doesn't work." 64 | }, 65 | 66 | "menuReport": { 67 | "message": "Rapporter om en cookie-advarsel", 68 | "description": "Menu option." 69 | }, 70 | 71 | "menuSupport": { 72 | "message": "Støt dette prosjekt", 73 | "description": "Menu option." 74 | }, 75 | 76 | "optionsTitle": { 77 | "message": "Indstillinger", 78 | "description": "Title for the options page." 79 | }, 80 | 81 | "optionsWhitelist": { 82 | "message": "Liste over alle hvidelistede websteder, et websted per linje:", 83 | "description": "Options text for whitelist box." 84 | }, 85 | 86 | "optionsButton": { 87 | "message": "Gem indstillingerne", 88 | "description": "Options text for SAVE button." 89 | }, 90 | 91 | "optionsSaved": { 92 | "message": "Indstillingene blev vellykket gemt.", 93 | "description": "Message after saving options." 94 | } 95 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "I don't care about cookies", 4 | "description": "Name of the extension." 5 | }, 6 | 7 | "extensionDescription": { 8 | "message": "Bye-bye to warnings.", 9 | "description": "Description of the extension." 10 | }, 11 | 12 | "reportSkippedTitle": { 13 | "message": "Extension is disabled on $HOSTNAME$.", 14 | "description": "Tells the user it cannot report a whitelisted domain.", 15 | "placeholders": { 16 | "hostname" : { 17 | "content" : "$1", 18 | "example" : "domain.com" 19 | } 20 | } 21 | }, 22 | 23 | "reportSkippedMessage": { 24 | "message": "You have disabled the extension on this website. Please enable it and check if you see the cookie warning before reporting.", 25 | "description": "Tells the user it cannot report a whitelisted domain." 26 | }, 27 | 28 | "reportConfirm": { 29 | "message": "Would you like to report a cookie warning on $HOSTNAME$?", 30 | "description": "Ask for confirmation before reporting a website. Don't use quotes here.", 31 | "placeholders": { 32 | "hostname" : { 33 | "content" : "$1", 34 | "example" : "domain.com" 35 | } 36 | } 37 | }, 38 | 39 | "menuEnable": { 40 | "message": "Enable extension on $HOSTNAME$", 41 | "description": "Menu option.", 42 | "placeholders": { 43 | "hostname" : { 44 | "content" : "$1", 45 | "example" : "domain.com" 46 | } 47 | } 48 | }, 49 | 50 | "menuDisable": { 51 | "message": "Disable extension on $HOSTNAME$", 52 | "description": "Menu option.", 53 | "placeholders": { 54 | "hostname" : { 55 | "content" : "$1", 56 | "example" : "domain.com" 57 | } 58 | } 59 | }, 60 | 61 | "menuIdle": { 62 | "message": "Extension doesn't work on this website", 63 | "description": "Menu option visible when enabling/disabling doesn't work." 64 | }, 65 | 66 | "menuReport": { 67 | "message": "Report a cookie warning", 68 | "description": "Menu option." 69 | }, 70 | 71 | "menuSupport": { 72 | "message": "Support this project", 73 | "description": "Menu option." 74 | }, 75 | 76 | "optionsTitle": { 77 | "message": "Settings", 78 | "description": "Title for the options page." 79 | }, 80 | 81 | "optionsWhitelist": { 82 | "message": "List of all whitelisted websites, one website per line:", 83 | "description": "Options text for whitelist box." 84 | }, 85 | 86 | "optionsButton": { 87 | "message": "Save settings", 88 | "description": "Options text for SAVE button." 89 | }, 90 | 91 | "optionsSaved": { 92 | "message": "Options successfully saved.", 93 | "description": "Message after saving options." 94 | } 95 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/_locales/hr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "I don't care about cookies", 4 | "description": "Name of the extension." 5 | }, 6 | 7 | "extensionDescription": { 8 | "message": "Pa-pa, upozorenja.", 9 | "description": "Description of the extension." 10 | }, 11 | 12 | "reportSkippedTitle": { 13 | "message": "Ekstenzija ne radi na $HOSTNAME$.", 14 | "description": "Tells the user it cannot report a whitelisted domain.", 15 | "placeholders": { 16 | "hostname" : { 17 | "content" : "$1", 18 | "example" : "domain.com" 19 | } 20 | } 21 | }, 22 | 23 | "reportSkippedMessage": { 24 | "message": "Ugasili ste ekstenziju na ovoj web stranici. Upalite ju i provjerite vidi li se upozorenje o kolačićima prije nego prijavite web stranicu.", 25 | "description": "Tells the user it cannot report a whitelisted domain." 26 | }, 27 | 28 | "reportConfirm": { 29 | "message": "Želite li prijaviti upozorenje o kolačićima na $HOSTNAME$?", 30 | "description": "Ask for confirmation before reporting a website. Don't use quotes here.", 31 | "placeholders": { 32 | "hostname" : { 33 | "content" : "$1", 34 | "example" : "domain.com" 35 | } 36 | } 37 | }, 38 | 39 | "menuEnable": { 40 | "message": "Upali ekstenziju na $HOSTNAME$", 41 | "description": "Menu option.", 42 | "placeholders": { 43 | "hostname" : { 44 | "content" : "$1", 45 | "example" : "domain.com" 46 | } 47 | } 48 | }, 49 | 50 | "menuDisable": { 51 | "message": "Ugasi ekstenziju na $HOSTNAME$", 52 | "description": "Menu option.", 53 | "placeholders": { 54 | "hostname" : { 55 | "content" : "$1", 56 | "example" : "domain.com" 57 | } 58 | } 59 | }, 60 | 61 | "menuIdle": { 62 | "message": "Ekstenzija ne radi na ovoj domeni", 63 | "description": "Menu option visible when enabling/disabling doesn't work." 64 | }, 65 | 66 | "menuReport": { 67 | "message": "Prijavite upozorenje o kolačićima", 68 | "description": "Menu option." 69 | }, 70 | 71 | "menuSupport": { 72 | "message": "Podržite ovaj projekt", 73 | "description": "Menu option." 74 | }, 75 | 76 | "optionsTitle": { 77 | "message": "Postavke", 78 | "description": "Title for the options page." 79 | }, 80 | 81 | "optionsWhitelist": { 82 | "message": "Popis svih dopuštenih web stranica, jedna po retku:", 83 | "description": "Options text for whitelist box." 84 | }, 85 | 86 | "optionsButton": { 87 | "message": "Snimi postavke", 88 | "description": "Options text for SAVE button." 89 | }, 90 | 91 | "optionsSaved": { 92 | "message": "Postavke su uspješno snimljene.", 93 | "description": "Message after saving options." 94 | } 95 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "I don't care about cookies", 4 | "description": "Name of the extension." 5 | }, 6 | 7 | "extensionDescription": { 8 | "message": "警告にサヨナラ。", 9 | "description": "Description of the extension." 10 | }, 11 | 12 | "reportSkippedTitle": { 13 | "message": "拡張はこのドメイン $HOSTNAME$ で無効とされています。", 14 | "description": "Tells the user it cannot report a whitelisted domain.", 15 | "placeholders": { 16 | "hostname" : { 17 | "content" : "$1", 18 | "example" : "domain.com" 19 | } 20 | } 21 | }, 22 | 23 | "reportSkippedMessage": { 24 | "message": "このウェブサイトでの拡張機能をあなたは無効にしています。有効化し、クッキー警告が出るか、バグ報告の前に確認してください。", 25 | "description": "Tells the user it cannot report a whitelisted domain." 26 | }, 27 | 28 | "reportConfirm": { 29 | "message": "$HOSTNAME$ でクッキー警告を出したいですか?", 30 | "description": "Ask for confirmation before reporting a website. Don't use quotes here.", 31 | "placeholders": { 32 | "hostname" : { 33 | "content" : "$1", 34 | "example" : "domain.com" 35 | } 36 | } 37 | }, 38 | 39 | "menuEnable": { 40 | "message": "$HOSTNAME$ で拡張を有効にします", 41 | "description": "Menu option.", 42 | "placeholders": { 43 | "hostname" : { 44 | "content" : "$1", 45 | "example" : "domain.com" 46 | } 47 | } 48 | }, 49 | 50 | "menuDisable": { 51 | "message": "$HOSTNAME$ で拡張を無効にします", 52 | "description": "Menu option.", 53 | "placeholders": { 54 | "hostname" : { 55 | "content" : "$1", 56 | "example" : "domain.com" 57 | } 58 | } 59 | }, 60 | 61 | "menuIdle": { 62 | "message": "拡張はこのサイトで動きません", 63 | "description": "Menu option visible when enabling/disabling doesn't work." 64 | }, 65 | 66 | "menuReport": { 67 | "message": "クッキー警告を報告する", 68 | "description": "Menu option." 69 | }, 70 | 71 | "menuSupport": { 72 | "message": "プロジェクトを支援したい", 73 | "description": "Menu option." 74 | }, 75 | 76 | "optionsTitle": { 77 | "message": "設定", 78 | "description": "Title for the options page." 79 | }, 80 | 81 | "optionsWhitelist": { 82 | "message": "無効化したいウェブサイトのリスト、1行ずつ:", 83 | "description": "Options text for whitelist box." 84 | }, 85 | 86 | "optionsButton": { 87 | "message": "設定を保存する", 88 | "description": "Options text for SAVE button." 89 | }, 90 | 91 | "optionsSaved": { 92 | "message": "オプションを保存しました。", 93 | "description": "Message after saving options." 94 | } 95 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/_locales/pt/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "extensionName": { 3 | "message": "I don't care about cookies", 4 | "description": "Name of the extension." 5 | }, 6 | 7 | "extensionDescription": { 8 | "message": "Diga adeus aos avisos.", 9 | "description": "Description of the extension." 10 | }, 11 | 12 | "reportSkippedTitle": { 13 | "message": "A extensão está desativada em $HOSTNAME$.", 14 | "description": "Tells the user it cannot report a whitelisted domain.", 15 | "placeholders": { 16 | "hostname" : { 17 | "content" : "$1", 18 | "example" : "domain.com" 19 | } 20 | } 21 | }, 22 | 23 | "reportSkippedMessage": { 24 | "message": "Desativou a extensão neste website. Por favor reative-a e verifique se recebe o aviso de cookies antes de o reportar.", 25 | "description": "Tells the user it cannot report a whitelisted domain." 26 | }, 27 | 28 | "reportConfirm": { 29 | "message": "Gostaria de reportar um aviso de cookies no website $HOSTNAME$?", 30 | "description": "Ask for confirmation before reporting a website. Don't use quotes here.", 31 | "placeholders": { 32 | "hostname" : { 33 | "content" : "$1", 34 | "example" : "domain.com" 35 | } 36 | } 37 | }, 38 | 39 | "menuEnable": { 40 | "message": "Ativar extensão em $HOSTNAME$", 41 | "description": "Menu option.", 42 | "placeholders": { 43 | "hostname" : { 44 | "content" : "$1", 45 | "example" : "domain.com" 46 | } 47 | } 48 | }, 49 | 50 | "menuDisable": { 51 | "message": "Desativar extensão em $HOSTNAME$", 52 | "description": "Menu option.", 53 | "placeholders": { 54 | "hostname" : { 55 | "content" : "$1", 56 | "example" : "domain.com" 57 | } 58 | } 59 | }, 60 | 61 | "menuIdle": { 62 | "message": "A extensão não funciona neste website", 63 | "description": "Menu option visible when enabling/disabling doesn't work." 64 | }, 65 | 66 | "menuReport": { 67 | "message": "Reportar um aviso de cookies", 68 | "description": "Menu option." 69 | }, 70 | 71 | "menuSupport": { 72 | "message": "Apoiar este projeto", 73 | "description": "Menu option." 74 | }, 75 | 76 | "optionsTitle": { 77 | "message": "Configurações", 78 | "description": "Title for the options page." 79 | }, 80 | 81 | "optionsWhitelist": { 82 | "message": "Lista de todos os websites excluídos, um website por linha:", 83 | "description": "Options text for whitelist box." 84 | }, 85 | 86 | "optionsButton": { 87 | "message": "Gravar configurações", 88 | "description": "Options text for SAVE button." 89 | }, 90 | 91 | "optionsSaved": { 92 | "message": "Configurações gravadas com sucesso.", 93 | "description": "Message after saving options." 94 | } 95 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/js/common2.js: -------------------------------------------------------------------------------- 1 | function getItem(hostname) 2 | { 3 | switch (hostname) 4 | { 5 | case 'pepephone.com': return {strict: true, key: 'cookiesChosen', value: 'done'}; 6 | } 7 | 8 | 9 | const parts = hostname.split('.'); 10 | 11 | if (parts.length > 2) 12 | { 13 | parts.shift(); 14 | return getItem(parts.join('.')); 15 | } 16 | 17 | return false; 18 | } 19 | 20 | 21 | let hostname = document.location.hostname.replace(/^w{2,3}\d*\./i, ''), 22 | item = getItem(hostname); 23 | 24 | if (item) { 25 | let value = sessionStorage.getItem(item.key); 26 | 27 | if (value == null || (item.strict && value != item.value)) { 28 | sessionStorage.setItem(item.key, item.value); 29 | document.location.reload(); 30 | } 31 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/js/common3.js: -------------------------------------------------------------------------------- 1 | function getItem(hostname) 2 | { 3 | switch (hostname) 4 | { 5 | case 'ants.gouv.fr': return {strict: true, key: 'cookieConsent', value: 'true'}; 6 | case 'eqmac.app': return {strict: false, key: 'EQM_PRIVACY_CONSENT_CHOSEN', value: 'true'}; 7 | case 'figuya.com': return {strict: false, key: 'cookie-dialog', value: 'closed'}; 8 | case 'scoodleplay.be': return {strict: false, key: 'scoodleAllowCookies', value: 'true'}; 9 | case 'lifesum.com': return {strict: false, key: 'accepted-cookies', value: '[]'}; 10 | case 'programmitv.it': return {strict: false, key: 'privacy_choices_made', value: 'OK'}; 11 | case 'nexus.gg': return {strict: true, key: 'cookie-notice:accepted', value: 'true'}; 12 | case 'streamelements.com': return {strict: true, key: 'StreamElements.gdprNoticeAccepted', value: 'true'}; 13 | case 'welt.de': return {strict: true, key: 'DM_prefs', value: '{"cookie_hint":true,"accept_cookies":false,"_childs":[],"_type":1}'}; 14 | 15 | case 'phoenix.de': return {strict: false, key: 'user_anonymous_profile', value: '{"config":{"tracking":false,"userprofile":false,"youtube":false,"twitter":false,"facebook":false,"iframe":false,"video":{"useSubtitles":false,"useAudioDescription":false}},"votings":[],"msgflash":[],"history":[]}'}; 16 | 17 | case 'klarna.com': 18 | return [ 19 | {strict: true, key: 'safe-storage/v1/tracking-consent/trackingConsentAnalyticsKey', value: 'KEEP_ALWAYS;;false'}, 20 | {strict: true, key: 'safe-storage/v1/tracking-consent/trackingConsentMarketingKey', value: 'KEEP_ALWAYS;;false'} 21 | ]; 22 | 23 | case 'volkskrant.nl': 24 | case 'dg.nl': 25 | case 'demorgen.be': 26 | case 'trouw.nl': 27 | case 'ad.nl': 28 | case 'parool.nl': 29 | case 'ed.nl': 30 | case 'bndestem.nl': 31 | case 'weser-kurier.de': 32 | return [ 33 | {strict: false, key: 'vl_disable_tracking', value: 'true'}, 34 | {strict: false, key: 'vl_disable_usecookie', value: 'necessary'} 35 | ]; 36 | } 37 | 38 | 39 | const parts = hostname.split('.'); 40 | 41 | if (parts.length > 2) 42 | { 43 | parts.shift(); 44 | return getItem(parts.join('.')); 45 | } 46 | 47 | return false; 48 | } 49 | 50 | 51 | let hostname = document.location.hostname.replace(/^w{2,3}\d*\./i, ''), 52 | counter = 0, 53 | items = getItem(hostname); 54 | 55 | if (items) { 56 | (items instanceof Array ? items : [items]).forEach(function(item) { 57 | let value = localStorage.getItem(item.key); 58 | 59 | if (value == null || (item.strict && value != item.value)) { 60 | localStorage.setItem(item.key, item.value); 61 | counter++; 62 | } 63 | }); 64 | 65 | if (counter > 0) 66 | document.location.reload(); 67 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/js/common4.js: -------------------------------------------------------------------------------- 1 | var counter = 0; 2 | 3 | var interval = setInterval(function(){ 4 | var element = document.querySelector('.CookiesOK'); 5 | counter++; 6 | 7 | if (element) 8 | element.click(); 9 | 10 | if (element || counter == 200) 11 | clearInterval(interval); 12 | }, 500); -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/js/common7.js: -------------------------------------------------------------------------------- 1 | var a = document.getElementById('ctl00_ctl01_ucCookieCheck_btnConfirm'); 2 | 3 | if (a) 4 | { 5 | document.getElementById('ctl00_ctl01_ucCookieCheck_rblAllowCookies_0').click(); 6 | a.click(); 7 | } -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/js/embeds.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const classname = Math.random().toString(36).replace(/[^a-z]+/g, ''); 3 | 4 | var l = document.location, 5 | is_audioboom = false, 6 | is_dailymotion = false, 7 | is_dailybuzz = false, 8 | is_playerclipslaliga = false; 9 | 10 | switch (l.hostname) { 11 | 12 | case 'embeds.audioboom.com': 13 | is_audioboom = true; 14 | break; 15 | 16 | case 'dailymotion.com': 17 | case 'www.dailymotion.com': 18 | is_dailymotion = l.pathname.indexOf('/embed') === 0; 19 | break; 20 | 21 | case 'geo.dailymotion.com': 22 | is_dailymotion = l.pathname.indexOf('/player') === 0; 23 | break; 24 | 25 | case 'dailybuzz.nl': 26 | is_dailybuzz = l.pathname.indexOf('/buzz/embed') === 0; 27 | break; 28 | 29 | case 'playerclipslaliga.tv': 30 | is_playerclipslaliga = true; 31 | break; 32 | } 33 | 34 | 35 | function searchEmbeds() { 36 | setTimeout(function() { 37 | 38 | // audioboom.com iframe embeds 39 | if (is_audioboom) { 40 | document.querySelectorAll('div[id^="cookie-modal"] .modal[style*="block"] .btn.mrs:not(.' + classname + ')').forEach(function(button) { 41 | button.className += ' ' + classname; 42 | button.click(); 43 | }); 44 | } 45 | 46 | // dailymotion.com iframe embeds 47 | else if (is_dailymotion) { 48 | document.querySelectorAll('.np_DialogConsent-accept:not(.' + classname + '), .consent_screen-accept:not(.' + classname + ')').forEach(function(button) { 49 | button.className += ' ' + classname; 50 | button.click(); 51 | }); 52 | } 53 | 54 | // dailybuzz.nl iframe embeds 55 | else if (is_dailybuzz) { 56 | document.querySelectorAll('#ask-consent #accept:not(.' + classname + ')').forEach(function(button) { 57 | button.className += ' ' + classname; 58 | button.click(); 59 | }); 60 | } 61 | 62 | // playerclipslaliga.tv iframe embeds 63 | else if (is_playerclipslaliga) { 64 | document.querySelectorAll('#cookies button[onclick*="saveCookiesSelection"]:not(.' + classname + ')').forEach(function(button) { 65 | button.className += ' ' + classname; 66 | button.click(); 67 | }); 68 | } 69 | 70 | // Give up 71 | else { 72 | return; 73 | } 74 | 75 | searchEmbeds(); 76 | }, 1000); 77 | } 78 | 79 | var start = setInterval(function() { 80 | var html = document.querySelector('html'); 81 | 82 | if (!html || (new RegExp(classname)).test(html.className)) 83 | return; 84 | 85 | html.className += ' ' + classname; 86 | searchEmbeds(); 87 | clearInterval(start); 88 | }, 500); 89 | })(); -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/menu/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta charset="utf-8"> 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> 6 | <link rel="stylesheet" href="style.css" /> 7 | </head> 8 | <body> 9 | <div style="width:220px; padding:10px 10px 0; text-align:center"> 10 | <img src="../../icons/32.png" /> 11 | 12 | <div style="text-align:left; padding:10px 0; word-wrap:break-word"> 13 | <a id="toggle"></a> 14 | <a id="refresh">↻</a> 15 | <a id="report"></a> 16 | <a id="options"></a> 17 | <a id="support"></a> 18 | </div> 19 | </div> 20 | 21 | <script src="script.js"></script> 22 | </body> 23 | </html> -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/menu/script.js: -------------------------------------------------------------------------------- 1 | var toggle = document.getElementById('toggle'), 2 | refresh = document.getElementById('refresh'), 3 | report = document.getElementById('report'), 4 | options = document.getElementById('options'), 5 | support = document.getElementById('support'), 6 | currentTab = false; 7 | 8 | support.textContent = chrome.i18n.getMessage("menuSupport"); 9 | report.textContent = chrome.i18n.getMessage("menuReport"); 10 | options.textContent = chrome.i18n.getMessage("optionsTitle"); 11 | 12 | 13 | function reloadMenu(enable_refresh_button) 14 | { 15 | chrome.tabs.query({active: true, currentWindow: true}, function(tabs) { 16 | chrome.runtime.sendMessage({ 17 | command: "get_active_tab", 18 | tabId: tabs[0].id 19 | }, function(message) { 20 | 21 | message = message || {}; 22 | currentTab = message.tab ? message.tab : false; 23 | 24 | if (message.tab && message.tab.hostname) 25 | { 26 | toggle.textContent = chrome.i18n.getMessage(message.tab.whitelisted ? "menuEnable" : "menuDisable", message.tab.hostname); 27 | toggle.style.display = 'block'; 28 | 29 | report.style.display = message.tab.whitelisted ? 'none' : 'block'; 30 | } 31 | else 32 | { 33 | toggle.textContent = ''; 34 | toggle.style.display = 'none'; 35 | 36 | report.style.display = 'none'; 37 | } 38 | 39 | if (typeof enable_refresh_button != 'undefined') 40 | { 41 | refresh.style.display = 'block'; 42 | toggle.style.display = 'none'; 43 | report.style.display = 'none'; 44 | } 45 | }); 46 | }); 47 | } 48 | 49 | 50 | toggle.addEventListener('click', function(e) { 51 | chrome.runtime.sendMessage({ 52 | command: "toggle_extension", 53 | tabId: currentTab.id 54 | }, function(message) { 55 | reloadMenu(true); 56 | }); 57 | }); 58 | 59 | refresh.addEventListener('click', function(e) { 60 | chrome.runtime.sendMessage({ 61 | command: "refresh_page", 62 | tabId: currentTab.id 63 | }, function(message) { 64 | window.close(); 65 | }); 66 | }); 67 | 68 | report.addEventListener('click', function(e) { 69 | chrome.runtime.sendMessage({ 70 | command: "report_website", 71 | tabId: currentTab.id 72 | }, function(message) { 73 | window.close(); 74 | }); 75 | }); 76 | 77 | support.addEventListener('click', function(e) { 78 | chrome.runtime.sendMessage({ 79 | command: "open_support_page", 80 | }, function(message) { 81 | window.close(); 82 | }); 83 | }); 84 | 85 | options.addEventListener('click', function(e) { 86 | chrome.runtime.sendMessage({ 87 | command: "open_options_page", 88 | }, function(message) { 89 | window.close(); 90 | }); 91 | }); 92 | 93 | reloadMenu(); -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/menu/style.css: -------------------------------------------------------------------------------- 1 | body {-moz-user-select:none; user-select:none; font:14px/18px Arial,Helvetica,sans-serif; background:#e8e8e8; padding:0; margin:0} 2 | 3 | a[id] {display:block; cursor:pointer; text-decoration:none; color:#000; padding:4px; border-top:1px dashed #888} 4 | a[id]:hover {background-color:#ccc} 5 | #toggle, #report {display:none} 6 | #refresh {display:none; text-align:center; font-size:200%; padding:10px; font-weight:bold; color:#cc4223} -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/options.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta charset="UTF-8"> 5 | 6 | <style> 7 | body {padding:20px} 8 | label {display:block; width:800px; padding:0 0 20px} 9 | textarea {width:100%; height:150px} 10 | </style> 11 | </head> 12 | <body> 13 | 14 | <h1 id="title"></h1> 15 | 16 | <label> 17 | <span id="whitelist_label"></span><br /> 18 | <textarea id="whitelist"></textarea> 19 | </label> 20 | 21 | <input type="button" id="save" value="" style="min-width:100px" /> <div id="status_saved" style="display:none; color:green; padding-left:5px"></div> 22 | 23 | <script src="options.js"></script> 24 | 25 | </body> 26 | </html> -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/data/options.js: -------------------------------------------------------------------------------- 1 | function save_options() 2 | { 3 | var whitelist = document.getElementById('whitelist').value.split("\n"), 4 | whitelisted_domains = {}; 5 | 6 | whitelist.forEach(function(line){ 7 | line = line.trim().replace(/^\w*\:?\/+/i, '').replace(/^w{2,3}\d*\./i, '').split('/')[0].split(':')[0]; 8 | 9 | if (line.length > 0 && line.length < 100) 10 | whitelisted_domains[line] = true; 11 | }); 12 | 13 | chrome.storage.local.set({whitelisted_domains:whitelisted_domains}, function(){ 14 | document.getElementById('status_saved').style.display = 'inline'; 15 | 16 | setTimeout(function() { 17 | document.getElementById('status_saved').style.display = 'none'; 18 | }, 2000); 19 | 20 | chrome.runtime.sendMessage('update_whitelist'); 21 | }); 22 | } 23 | 24 | function restore_options() { 25 | chrome.storage.local.get({ 26 | whitelisted_domains: {} 27 | }, function(items) { 28 | document.getElementById('whitelist').value = Object.keys(items.whitelisted_domains).sort().join("\n"); 29 | }); 30 | } 31 | 32 | document.title = document.getElementById('title').textContent = chrome.i18n.getMessage("optionsTitle") + ' - ' + chrome.i18n.getMessage("extensionName"); 33 | document.getElementById('whitelist_label').textContent = chrome.i18n.getMessage("optionsWhitelist"); 34 | document.getElementById('save').setAttribute('value', chrome.i18n.getMessage("optionsButton")); 35 | document.getElementById('status_saved').textContent = chrome.i18n.getMessage("optionsSaved"); 36 | 37 | document.addEventListener('DOMContentLoaded', restore_options); 38 | document.getElementById('save').addEventListener('click', save_options); -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/extension_3_4_6_0.crx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhood-io/webhood/174e8be4b821b7a232410907ceee49afb16330dd/src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/extension_3_4_6_0.crx -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhood-io/webhood/174e8be4b821b7a232410907ceee49afb16330dd/src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/128.png -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhood-io/webhood/174e8be4b821b7a232410907ceee49afb16330dd/src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/16.png -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhood-io/webhood/174e8be4b821b7a232410907ceee49afb16330dd/src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/32.png -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webhood-io/webhood/174e8be4b821b7a232410907ceee49afb16330dd/src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/icons/48.png -------------------------------------------------------------------------------- /src/scanner/extensions/fihnjjcciajhdojfnbdddfaoknhalnja/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "update_url": "https://clients2.google.com/service/update2/crx", 3 | 4 | "manifest_version": 2, 5 | "name": "__MSG_extensionName__", 6 | "short_name": "__MSG_extensionDescription__", 7 | "default_locale": "en", 8 | "version": "3.4.6", 9 | "icons": { 10 | "16": "icons/16.png", 11 | "48": "icons/48.png", 12 | "128": "icons/128.png" 13 | }, 14 | "author": "Daniel Kladnik @ kiboke studio", 15 | "permissions": [ 16 | "tabs", 17 | "storage", 18 | "http://*/*", 19 | "https://*/*", 20 | "notifications", 21 | "webRequest", 22 | "webRequestBlocking", 23 | "webNavigation" 24 | ], 25 | "background": { 26 | "scripts": [ 27 | "data/rules.js", 28 | "data/context-menu.js" 29 | ] 30 | }, 31 | "options_ui": { 32 | "page": "data/options.html", 33 | "chrome_style": true 34 | }, 35 | "browser_action": { 36 | "default_popup": "data/menu/index.html", 37 | "default_icon": { 38 | "16": "icons/16.png", 39 | "32": "icons/32.png" 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /src/scanner/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webhood/scanner", 3 | "version": "1.0.0", 4 | "description": "Webhood scanner", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "test": "ts-mocha --exit test/*.ts -r dotenv/config", 8 | "test:e2e": "ts-mocha --exit test/browserTest.ts -g E2E* -r dotenv/config", 9 | "build": "pkgroll", 10 | "dev": "pkgroll && NODE_ENV=development node --require dotenv/config dist/main.js", 11 | "prod": "node dist/main.js" 12 | }, 13 | "author": "Markus Lehtonen @ Webhood", 14 | "private": true, 15 | "license": "GPL-3.0-only", 16 | "dependencies": { 17 | "async-mutex": "^0.4.1", 18 | "dotenv": "^16.0.3", 19 | "eventsource": "^2.0.2", 20 | "ip": "^2.0.1", 21 | "jszip": "^3.10.1", 22 | "memorystream": "^0.3.1", 23 | "pino": "^8.19.0", 24 | "pocketbase": "^0.21.0", 25 | "puppeteer-core": "^22.4.1", 26 | "puppeteer-extra": "^3.3.6", 27 | "puppeteer-extra-plugin-recaptcha": "^3.6.8", 28 | "puppeteer-extra-plugin-stealth": "^2.11.2", 29 | "uuid": "^9.0.1" 30 | }, 31 | "devDependencies": { 32 | "@types/chai": "^4.3.9", 33 | "@types/eventsource": "^1.1.12", 34 | "@types/expect": "^24.3.0", 35 | "@types/ip": "^1.1.3", 36 | "@types/memorystream": "^0.3.3", 37 | "@types/mocha": "^10.0.3", 38 | "@types/uuid": "^9.0.4", 39 | "@webhood/types": "*", 40 | "chai": "^4.3.10", 41 | "mocha": "^10.2.0", 42 | "pino-pretty": "^10.3.1", 43 | "pkgroll": "^2.0.1", 44 | "ts-mocha": "^10.0.0", 45 | "ts-node": "^10.9.1", 46 | "tsx": "^3.14.0", 47 | "typescript": "^5.2.2" 48 | }, 49 | "volta": { 50 | "node": "21.6.2" 51 | }, 52 | "type": "module" 53 | } 54 | -------------------------------------------------------------------------------- /src/scanner/src/config/main.ts: -------------------------------------------------------------------------------- 1 | const cloudUrl = 2 | process.env.CLOUD_URL || "https://api.cloud.webhood.io/api/beta"; 3 | 4 | export default { 5 | cloudUrl, 6 | }; 7 | -------------------------------------------------------------------------------- /src/scanner/src/errors.ts: -------------------------------------------------------------------------------- 1 | class WebhoodScannerError extends Error { 2 | constructor(message: string) { 3 | super(message); 4 | this.name = this.constructor.name; 5 | } 6 | } 7 | 8 | class WebhoodScannerTimeoutError extends WebhoodScannerError { 9 | constructor(message: string) { 10 | super(message); 11 | this.name = this.constructor.name; 12 | } 13 | } 14 | 15 | class WebhoodScannerPageError extends WebhoodScannerError { 16 | constructor(message: string) { 17 | super(message); 18 | this.name = this.constructor.name; 19 | } 20 | } 21 | 22 | class WebhoodScannerBackendError extends WebhoodScannerError { 23 | constructor(message: string) { 24 | super(message); 25 | this.name = this.constructor.name; 26 | } 27 | } 28 | 29 | class WebhoodScannerInvalidConfigError extends WebhoodScannerError { 30 | constructor(message: string) { 31 | super(message); 32 | this.name = this.constructor.name; 33 | } 34 | } 35 | 36 | export { 37 | WebhoodScannerBackendError, 38 | WebhoodScannerError, 39 | WebhoodScannerInvalidConfigError, 40 | WebhoodScannerPageError, 41 | WebhoodScannerTimeoutError, 42 | }; 43 | -------------------------------------------------------------------------------- /src/scanner/src/logging.ts: -------------------------------------------------------------------------------- 1 | import pino from "pino"; 2 | 3 | const transport = () => { 4 | console.log(process.env.NODE_ENV); 5 | if (process.env.NODE_ENV === "development") 6 | return { 7 | transport: { 8 | target: "pino-pretty", 9 | options: { 10 | colorize: true, 11 | }, 12 | }, 13 | }; 14 | }; 15 | 16 | const logLevel = () => { 17 | return process.env.LOG_LEVEL || "info"; 18 | }; 19 | 20 | export const logger = pino({ 21 | level: logLevel(), 22 | ...transport(), 23 | }); 24 | -------------------------------------------------------------------------------- /src/scanner/src/rateConfig.ts: -------------------------------------------------------------------------------- 1 | type RateConfig = { 2 | goto_timeout: number; 3 | }; 4 | 5 | type RateConfigObject = { 6 | fast: RateConfig; 7 | balanced: RateConfig; 8 | slow: RateConfig; 9 | }; 10 | 11 | export const rateConfig: RateConfigObject = { 12 | fast: { 13 | goto_timeout: 5_000, 14 | }, 15 | balanced: { 16 | goto_timeout: 10_000, 17 | }, 18 | slow: { 19 | goto_timeout: 30_000, 20 | }, 21 | }; 22 | 23 | const DEFAULT_RATE = "fast"; 24 | 25 | const width = 1920; 26 | const height = 1080; 27 | 28 | const WAIT_FOR_DOWNLOAD_TIMEOUT = 5_000; // 5 seconds. This is the time to wait for a download to start without knowing if there will be a download or not 29 | 30 | export { DEFAULT_RATE, WAIT_FOR_DOWNLOAD_TIMEOUT, height, width }; 31 | -------------------------------------------------------------------------------- /src/scanner/src/utils/dnsUtils.ts: -------------------------------------------------------------------------------- 1 | import ip from "ip"; 2 | import dns from "node:dns"; 3 | import url from "node:url"; 4 | 5 | export function resolvesPublicIp(scanurl: string): Promise<string> { 6 | const hostname = new url.URL(scanurl).hostname; 7 | return new Promise((resolve, reject) => { 8 | dns.lookup(hostname, (err, address) => { 9 | if (err) { 10 | reject(err); 11 | } else { 12 | if (!ip.isPrivate(address)) { 13 | resolve(address); 14 | } else { 15 | reject(new Error("Not a public IP")); 16 | } 17 | } 18 | }); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/scanner/src/utils/memoryAuthStore.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { BaseAuthStore } from "pocketbase"; 3 | 4 | export class EnvAuthStore extends BaseAuthStore { 5 | save(token: any, model: any) { 6 | super.save(token, model); 7 | // save model to file 8 | fs.writeFileSync(".model.json", JSON.stringify(model)); 9 | process.env.SCANNER_TOKEN = token; 10 | } 11 | get token() { 12 | return process.env.SCANNER_TOKEN || ""; 13 | } 14 | get model() { 15 | return JSON.parse(fs.readFileSync(".model.json").toString()); 16 | } 17 | get isValid() { 18 | return true; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/scanner/src/utils/other.ts: -------------------------------------------------------------------------------- 1 | import { ScansResponse } from "@webhood/types"; 2 | import { logger } from "../logging"; 3 | import { errorMessage } from "../server"; 4 | import { resolvesPublicIp } from "./dnsUtils"; 5 | 6 | export async function filterScans( 7 | scans: ScansResponse[] 8 | ): Promise<ScansResponse[]> { 9 | let filteredScans: ScansResponse[] = []; 10 | if (!isRestrictedPrivateIp()) return scans; 11 | await Promise.all( 12 | scans.map(async (scan) => { 13 | try { 14 | await resolvesPublicIp(scan.url); 15 | filteredScans.push(scan); 16 | } catch (e) { 17 | logger.info({ 18 | type: "errorResolvingPublicIp", 19 | scanId: scan.id, 20 | error: e, 21 | }); 22 | errorMessage( 23 | "Not a public IP or could not resolve hostname while SCANNER_NO_PRIVATE_IPS set to true", 24 | scan.id 25 | ); 26 | } 27 | }) 28 | ); 29 | return filteredScans; 30 | } 31 | export function isRestrictedPrivateIp(): boolean { 32 | return process.env.SCANNER_NO_PRIVATE_IPS === "true"; 33 | } 34 | 35 | export function isObject(item: any) { 36 | return item && typeof item === "object" && !Array.isArray(item); 37 | } 38 | 39 | export function mergeDeep(...objects: any): object { 40 | const isObject = (obj: any) => obj && typeof obj === "object"; 41 | 42 | return objects.reduce((prev: any, obj: any) => { 43 | Object.keys(obj).forEach((key) => { 44 | const pVal = prev[key]; 45 | const oVal = obj[key]; 46 | 47 | if (Array.isArray(pVal) && Array.isArray(oVal)) { 48 | prev[key] = pVal.concat(...oVal); 49 | } else if (isObject(pVal) && isObject(oVal)) { 50 | prev[key] = mergeDeep(pVal, oVal); 51 | } else { 52 | prev[key] = oVal; 53 | } 54 | }); 55 | 56 | return prev; 57 | }, {}); 58 | } 59 | -------------------------------------------------------------------------------- /src/scanner/src/utils/pbUtils.ts: -------------------------------------------------------------------------------- 1 | import { ScannerConfig, ScannersResponse } from "@webhood/types"; 2 | import PocketBase from "pocketbase"; 3 | import * as errors from "../errors"; 4 | import { EnvAuthStore } from "./memoryAuthStore"; 5 | 6 | export const pb = new PocketBase(process.env.ENDPOINT, new EnvAuthStore()); 7 | pb.autoCancellation(false); 8 | 9 | export async function getBrowserInfo(): Promise<{ 10 | scannerObject: ScannersResponse; 11 | scannerConfig: ScannerConfig; 12 | }> { 13 | // Get config for current scanner 14 | const authModel = await pb.collection("api_tokens").authRefresh({ 15 | expand: "config", 16 | }); 17 | const data = authModel.record.expand?.config; 18 | if (!data?.config) { 19 | throw new errors.WebhoodScannerInvalidConfigError("Could not get config"); 20 | } 21 | return { scannerObject: data, scannerConfig: data.config as ScannerConfig }; 22 | } 23 | 24 | // This function should only be used when you know that the config is up to date. 25 | // usually this is called after getBrowserInfo is called 26 | export function getScanInfoStatic() { 27 | const model = pb.authStore.model; 28 | if (!model) { 29 | throw new errors.WebhoodScannerInvalidConfigError("Could not get config"); 30 | } 31 | return model.expand.config.config as ScannerConfig; 32 | } 33 | -------------------------------------------------------------------------------- /src/scanner/test/test.ts: -------------------------------------------------------------------------------- 1 | import { ScansResponse } from "@webhood/types"; 2 | import { expect } from "chai"; 3 | import "mocha"; // required for types 4 | import { filterScans } from "../src/utils/other"; 5 | import { pb } from "../src/utils/pbUtils"; 6 | import { randomSlug } from "./utils"; 7 | 8 | describe("other scanner tests", () => { 9 | it("should filter local site", async () => { 10 | const scansModel = pb.collection("scans"); 11 | const localScan = await scansModel.create({ 12 | url: "https://localhost", 13 | status: "pending", 14 | slug: randomSlug(), 15 | }); 16 | const publicScan = await scansModel.create({ 17 | url: "https://google.com", 18 | status: "pending", 19 | slug: randomSlug(), 20 | }); 21 | const scans = [localScan, publicScan] as ScansResponse[]; 22 | process.env.SCANNER_NO_PRIVATE_IPS = "true"; 23 | const filtered = await filterScans(scans); 24 | process.env.SCANNER_NO_PRIVATE_IPS = "false"; 25 | const notFiltered = await filterScans(scans); 26 | expect(scans).to.have.length(2); 27 | expect(filtered).to.have.length(1); 28 | expect(filtered[0].url).to.equal("https://google.com"); 29 | expect(notFiltered).to.have.length(2); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/scanner/test/utils.ts: -------------------------------------------------------------------------------- 1 | function randomIntFromInterval(min: number, max: number) { 2 | // min and max included 3 | return Math.floor(Math.random() * (max - min + 1) + min); 4 | } 5 | export const randomSlug = () => 6 | `test-${randomIntFromInterval(1, 10000).toString()}`; 7 | -------------------------------------------------------------------------------- /src/scanner/test/utilsTest.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import {resolvesPublicIp} from "../src/utils/dnsUtils"; 3 | 4 | describe("test DNS utils", () => { 5 | it("should resolve google.com", async () => { 6 | await resolvesPublicIp("https://google.com"); 7 | }); 8 | it("should error on localhost", async () => { 9 | let error; 10 | try { 11 | await resolvesPublicIp("https://localhost"); 12 | } catch (e) { 13 | error = e; 14 | } 15 | expect(error).to.not.be.undefined; 16 | // expect error to read "Not a public IP" 17 | expect(error.message).to.equal("Not a public IP"); 18 | }); 19 | }); -------------------------------------------------------------------------------- /src/scanner/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "esModuleInterop": true, 5 | "target": "ESNext", 6 | "moduleResolution": "Node", 7 | "outDir": "dist", 8 | "forceConsistentCasingInFileNames": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "isolatedModules": false, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "useUnknownInCatchVariables": false, 14 | "inlineSourceMap": true, 15 | "types": ["node", "mocha", "chai", "expect"] 16 | }, 17 | "ts-node": { 18 | "esm": true, 19 | "files": true, 20 | }, 21 | "lib": ["esnext", "dom"] 22 | } -------------------------------------------------------------------------------- /src/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@webhood/types", 3 | "version": "1.0.0", 4 | "repository": "https://github.com/webhood-io/webhood", 5 | "author": "Markus Lehtonen @ Webhood", 6 | "main": "index.ts", 7 | "license": "GPL-3.0", 8 | "scripts": { 9 | "gen": "pocketbase-typegen --db ../backend/src/pb_data/data.db --out db.ts", 10 | "pub": "yarn publish --access=public --no-git-tag-version" 11 | }, 12 | "dependencies": { 13 | "puppeteer-core": "latest" 14 | }, 15 | "devDependencies": { 16 | "pocketbase-typegen": "latest" 17 | } 18 | } 19 | --------------------------------------------------------------------------------