diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a29a110 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,31 @@ +# CODEOWNERS file for how2validate project +# This file assigns ownership for different parts of the repository +# Each line matches a file pattern to one or more code owners + +# Default owners (fallback for all files not explicitly mentioned) +* @blackplums/admin + +# Owners for specific areas of the project + +# Workflow +/.github @blackplums/admin @vigneshkna +/.gitignore @blackplums/admin @vigneshkna + +# Source Packages +/src/docker/ @blackplums/admin @vigneshkna +/src/js/ @blackplums/admin @vigneshkna +/src/python/ @blackplums/admin @vigneshkna +/src/scripts/ @blackplums/admin @vigneshkna + + +# Documentation +/docs/ @blackplums/admin @vigneshkna @manimarans +/README.md @blackplums/admin @vigneshkna + +# Root files +/CODE_OF_CONDUCT.md @blackplums/admin @vigneshkna +/CODEOWNERS @blackplums/admin @vigneshkna +/CONTRIBUTING.md @blackplums/admin @vigneshkna +/LICENSE @blackplums/admin @vigneshkna +/SECURITY.md @blackplums/admin @vigneshkna + diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e2c6447 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +VigneshKna@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..7f535b6 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1 @@ +# Contribution \ No newline at end of file diff --git a/SECURITY.md b/.github/SECURITY.md similarity index 90% rename from SECURITY.md rename to .github/SECURITY.md index 139769c..10db9df 100644 --- a/SECURITY.md +++ b/.github/SECURITY.md @@ -4,7 +4,7 @@ If you discover a security vulnerability within this project, please follow these steps to report it: -1. **Create a New Issue**: Go to the [Issues](../../issues) section of this repository. +1. **Create a New Issue**: Go to the [Issues](../../../issues/new/choose) section of this repository. 2. **Tag the Issue**: Use the **`security`** tag when creating the issue to highlight that it’s a security-related concern. diff --git a/.github/actions/bump-version/action.yml b/.github/actions/bump-version/action.yml new file mode 100644 index 0000000..1df7a86 --- /dev/null +++ b/.github/actions/bump-version/action.yml @@ -0,0 +1,34 @@ +name: "Bump Version" +description: "Detect semver bump type and update Config.ini" + +inputs: + bump-type: + description: "Version bump type: patch, minor, major, beta" + required: true + +outputs: + new-version: + description: "The new version bumped from Config.ini" + value: ${{ steps.bump.outputs.new-version }} + +runs: + using: "composite" + steps: + - name: Run bump_version.py + id: bump + shell: bash + run: | + # Run the version bump script with the specified bump type + OUTPUT=$(python src/python/bump_version.py ${{ inputs.bump-type }}) + + # Print all output for logging + echo "$OUTPUT" + + # Extract the new version from the 'NewVersion:' line + NEW_VERSION=$(echo "$OUTPUT" | grep "NewVersion:" | awk '{print $2}') + + # Set output for workflow + echo "new-version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + + # Optional log for clarity + echo "Bumped version: $NEW_VERSION" diff --git a/.github/actions/install-deps/action.yml b/.github/actions/install-deps/action.yml new file mode 100644 index 0000000..6f4c3d3 --- /dev/null +++ b/.github/actions/install-deps/action.yml @@ -0,0 +1,21 @@ +name: "Install Deps" +description: "Install Python dependencies" + +runs: + using: "composite" + steps: + - name: Update and Install dependencies + working-directory: ./src/python + shell: bash + run: | + echo "::notice::######################### Upgrading pip ###################################" + python -m pip install --upgrade pip + pip install pip-tools + pip-compile ./requirements.in + pip-compile --upgrade ./requirements.in + + echo "::notice::######################### Installing dependencies #########################" + pip install -r requirements.txt + + echo "::notice::################## End of Update and Install dependencies #################" + diff --git a/.github/actions/publish-docker/action.yml b/.github/actions/publish-docker/action.yml new file mode 100644 index 0000000..fe06984 --- /dev/null +++ b/.github/actions/publish-docker/action.yml @@ -0,0 +1,67 @@ +name: "Publish Docker Image" +description: "Composite action: Build, push Docker image and generate attestation" + +inputs: + registry: + description: 'Container registry (eg. ghcr.io)' + required: true + default: 'ghcr.io' + image_name: + description: 'Repository/name for image (eg. owner/repo)' + required: true + context: + description: 'Build context path' + required: true + default: './src/docker' + dockerfile: + description: 'Path to Dockerfile' + required: true + default: './src/docker/Dockerfile' + push: + description: 'Whether to push the image (true/false)' + required: true + default: 'true' + github_token: + description: 'GitHub token to authenticate to registry' + required: true + +runs: + using: composite + steps: + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ github.actor }} + password: ${{ inputs.github_token }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ inputs.registry }}/${{ inputs.image_name }} + + - name: Build and push Docker image + id: push + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + push: ${{ inputs.push }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-name: ${{ inputs.registry }}/${{ inputs.image_name }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + +outputs: + digest: + description: 'Image digest produced by docker/build-push-action' + value: ${{ steps.push.outputs.digest }} + tags: + description: 'Computed image tags' + value: ${{ steps.meta.outputs.tags }} \ No newline at end of file diff --git a/.github/actions/publish-pypi/action.yml b/.github/actions/publish-pypi/action.yml new file mode 100644 index 0000000..db14e98 --- /dev/null +++ b/.github/actions/publish-pypi/action.yml @@ -0,0 +1,26 @@ +name: 'Build Python Package' +description: 'Sets up Python environment and builds package distributions' + +inputs: + version: + description: "Release version" + required: true + +runs: + using: 'composite' + steps: + - name: Build package distributions + working-directory: ./src/python + shell: bash + run: python setup.py sdist bdist_wheel + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: python-v${{ inputs.version }} + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ \ No newline at end of file diff --git a/.github/actions/release-tag/action.yml b/.github/actions/release-tag/action.yml new file mode 100644 index 0000000..c0c692c --- /dev/null +++ b/.github/actions/release-tag/action.yml @@ -0,0 +1,25 @@ +name: "Create Release" +description: "Tag current version and publish GitHub Release" + +inputs: + version: + description: "Release version" + required: true + +runs: + using: "composite" + steps: + - name: Create Git tag + shell: bash + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag v${{ inputs.version }} + git push origin v${{ inputs.version }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ inputs.version }} + name: Release v${{ inputs.version }} + body: "Automated release for version ${{ inputs.version }}" diff --git a/.github/actions/test-package/action.yml b/.github/actions/test-package/action.yml new file mode 100644 index 0000000..207b1e1 --- /dev/null +++ b/.github/actions/test-package/action.yml @@ -0,0 +1,32 @@ +name: "Test Python Package" +description: "Composite action: Runs Testing python packages" + +inputs: + test-command: + description: "Command to run tests" + required: false + default: "pytest" + +outputs: + tests-passed: + description: "true when tests passed" + value: ${{ steps.run_tests.outputs.tests_passed }} + +runs: + using: "composite" + steps: + - name: Run tests + id: run_tests + shell: bash + working-directory: ./src/python/tests + run: | + set -e + echo "::notice::Running tests with: ${{ inputs.test-command }}" + if bash -lc "${{ inputs.test-command }}"; then + echo "::notice::Tests passed!" + echo "tests_passed=true" >> "$GITHUB_OUTPUT" + else + echo "::error::Tests failed!" + echo "::error::tests_passed=false" >> "$GITHUB_OUTPUT" + exit 1 + fi \ No newline at end of file diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml new file mode 100644 index 0000000..b047033 --- /dev/null +++ b/.github/workflows/build_release.yml @@ -0,0 +1,80 @@ +name: Release Package + +on: + # Manual trigger + workflow_dispatch: + inputs: + bump-type: + description: "Semver bump type [patch|minor|major|beta]" + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + - beta + + # Scheduled trigger: every week at Sunday 00:00 UTC + schedule: + - cron: '0 0 * * 0' # weekly, Sunday 00:00 UTC + + # Trigger on changes to src/python or src/docker + push: + branches: + - main # or your default branch + paths: + - 'src/python/**' + - 'src/docker/**' + +jobs: + Build-and-Release: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + packages: write + discussions: write + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies + uses: ./.github/actions/install-deps + + - name: Test packages + id: test + uses: ./.github/actions/test-package + + - name: Bump version + id: bump + uses: ./.github/actions/bump-version + if: ${{ success() && steps.test.outcome == 'success' }} + with: + bump-type: ${{ github.event.inputs.bump-type || 'patch' }} + + - name: Publish to PyPI + id: publish_pypi + uses: ./.github/actions/publish-pypi + if: ${{ success() && steps.bump.outcome == 'success' }} + with: + version: ${{ steps.bump.outputs.new-version }} + + - name: Publish Docker + id: publish_docker + uses: ./.github/actions/publish-docker + if: ${{ success() && steps.bump.outcome == 'success' }} + with: + version: ${{ steps.bump.outputs.new-version }} + + - name: Tag & Release + id: release + uses: ./.github/actions/release-tag + if: ${{ success() && steps.publish_pypi.outcome == 'success' && steps.publish_docker.outcome == 'success' }} + with: + version: ${{ steps.bump.outputs.new-version }} diff --git a/.github/workflows/notify-on-release.yml b/.github/workflows/notify-on-release.yml new file mode 100644 index 0000000..80ee924 --- /dev/null +++ b/.github/workflows/notify-on-release.yml @@ -0,0 +1,35 @@ +name: 'Notify on New Release' + +# This workflow runs when a new release is 'published'. +# It will not run on drafts or pre-releases unless they are marked as 'latest'. +on: + release: + types: [published] + +# Sets the permissions for the GITHUB_TOKEN to allow creating discussions. +permissions: + discussions: write + +jobs: + create_discussion_post: + name: 'Create Discussion Post' + runs-on: ubuntu-latest + steps: + - name: 'Create Discussion Post for Release' + env: + # The GITHUB_TOKEN is automatically provided by GitHub Actions. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh discussion create \ + --repo ${{ github.repository }} \ + --category "release-announcements" \ + --title "New Release: ${{ github.event.release.name }}" \ + --body "A new version, **${{ github.event.release.tag_name }}**, has just been released! + + ### Release Notes + ${{ github.event.release.body }} + + --- + + You can view the full release details and download assets here: + **${{ github.event.release.html_url }}**" \ No newline at end of file diff --git a/.github/workflows/test_package.yml b/.github/workflows/test_package.yml new file mode 100644 index 0000000..ea7b8d1 --- /dev/null +++ b/.github/workflows/test_package.yml @@ -0,0 +1,30 @@ +name: Test python package + +# Configures this workflow to run every time a change is pushed to the branch. +on: + push: + branches: + - 'feature/*' + - 'develop/*' + paths: + - 'src/docker/**' + - 'src/python/**' + +jobs: + Test-Package: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install dependencies + uses: ./.github/actions/install-deps + + - name: Test packages + id: test + uses: ./.github/actions/test-package diff --git a/.gitignore b/.gitignore index b62de68..05b116b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ __pycache__/ *.env *.venv *.envrc +*.coverage +*htmlcov +*.pytest_* python-*.egg-info/ # Distribution / Packaging @@ -30,6 +33,9 @@ npm-debug.log* # Production dist/ +# Build +build/ + # Docker docker-compose.yml .dockerignore diff --git a/README.md b/README.md index addf25c..0d85b5b 100644 --- a/README.md +++ b/README.md @@ -1 +1,272 @@ -# how2validate \ No newline at end of file +![How2Validate](https://socialify.git.ci/Blackplums/how2validate/image?font=Inter&name=1&pattern=Solid&theme=Auto) + + +### About + +How2Validate is a versatile security tool designed to streamline the process of validating sensitive secrets across various platforms and services. + +Whether you're a developer, security professional, or DevOps engineer, How2Validate empowers you to ensure the authenticity and security of your API keys, tokens, and other critical information. + +By leveraging the power of Python, JavaScript, and Docker, How2Validate offers a flexible and efficient solution for validating secrets against official provider endpoints. Its user-friendly command-line interface (CLI) makes it easy validating secrets, allowing you to quickly and accurately verify the integrity of your sensitive data. + + +### Why How2Validate? + +In today's digital landscape, the exposure of sensitive information such as API keys, passwords, and tokens can lead to significant security breaches. These vulnerabilities often arise from: + +- **Leaked API Keys**: Unintentional exposure through public repositories or logs. +- **Invalid Credentials**: Using outdated or incorrect credentials that can compromise systems. +- **Misconfigured Secrets**: Improperly managed secrets leading to unauthorized access. + +**How2Validate** addresses these concerns by providing a robust solution to verify the authenticity and validity of your secrets directly with official providers. This proactive approach helps in: + +- **Mitigating Risks**: Prevent unauthorized access by ensuring only active secrets are used. +- **Enhancing Security Posture**: Strengthen your application's security by regularly validating secrets. + + +### Features + +**How2Validate** offers a range of features designed to enhance the security and efficiency of secret management: + +- **Validate API Keys, Passwords, and Sensitive Information**: Interacts with official provider authentication endpoints to ensure the authenticity of secrets. +- **Cross-Platform Support**: Available for JavaScript, Python, and Docker environments. +- **Easy to Use**: Simplifies secret validation with straightforward commands and functions. +- **Real-Time Feedback**: Instantly know the status of your secrets — whether they are active or not. +- **Detailed Reporting**: Receive comprehensive reports on secret validation (Alpha Feature). +- **Updating Providers**: Keep the tool up-to-date with the latest secret providers and their secret types. + +## Join Our Community discussions + +Have questions? Feedback? Jump in and hang out with us + +Join our [GitHub Community Discussion](https://github.com/Blackplums/how2validate/discussions) + +## Packages + +**How2Validate** is available for multiple platforms, ensuring seamless secret validation process. Choose the package manager that best fits your project needs: + +### Package Statistics + +Stay updated with the latest versions and downloads: + +
+ + jsr.io + + + pypi.org + + + container image + +
+ + + +## Installation + +Installing **How2Validate** is straightforward, whether you're working with JavaScript, Python, or Docker. Follow the instructions below to set up the package in your environment. + +### JavaScript + +#### Using NPM +```bash +npx jsr add @how2validate/how2validate +``` + +#### Using pnpm +```bash +pnpm dlx jsr add @how2validate/how2validate +``` + +#### Using Bun +```bash +bunx jsr add @how2validate/how2validate +``` + +#### Using Yarn +```bash +yarn dlx jsr add @how2validate/how2validate +``` + +#### Using Deno +```bash +deno add jsr:@how2validate/how2validate +``` + + +### Python + +#### Using pip +```bash +pip install how2validate +``` + + +### Docker +Get the latest version from [GitHub Package Registry](https://github.com/Blackplums/how2validate/pkgs/container/how2validate) + +#### Using docker +```bash +docker pull ghcr.io/blackplums/how2validate:main +``` + +## Usage + +**How2Validate** can be used both programmatically and via the command-line interface (CLI). Below are detailed instructions for JavaScript, Python, and CLI usage. + +### JavaScript + +#### Importing and Using the Validate Function + +```javascript +import { validate } from '@how2validate/how2validate'; + +# Validate secrets programmatically +var validation_result = await validate( + provider="NPM", + service="NPM Access Token", + secret="<>", + response=False, + report="useremail@domain.com" +) +print(validation_result) +``` + + +### Python + +#### Importing and Using the Validate Function + +```python +from how2validate import validate + +# Validate secrets programmatically +validation_result = validate( + provider="NPM", + service="NPM Access Token", + secret="<>", + response=False, + report="useremail@domain.com" +) +print(validation_result) +``` + +## CLI + +### Detailed CLI Help + +The **How2Validate** tool provides multiple command-line options for validating secrets with precision. + +To see all available commands, use: + +```bash +how2validate --help + +usage: How2Validate Tool [options] + +Validate various types of secrets for different services. + +options: + -h, --help show this help message and exit + -secretscope Explore the secret universe. Your next target awaits. + -p, --provider Specify your provider. Unleash your validation arsenal. + -s, --service Specify your target service. Validate your secrets with precision. + -sec, --secret Unveil your secrets to verify their authenticity. + -r, --response Monitor the status. View if your secret is Active or InActive. + -R, --report Get detailed reports. Receive validated secrets via email [Alpha Feature]. + -v, --version Expose the version. + --update Hack the tool to the latest version. + +Ensuring the authenticity of your secrets. +``` + +### Example Command + +#### Validate a Secret + +```bash +how2validate --provider NPM --service "NPM Access Token" --secret "<>" + +-- OR -- + +how2validate -p NPM -s "NPM Access Token" -sec "<>" +``` + +#### Validate with Response Status + +```bash +how2validate --provider NPM --service "NPM Access Token" --secret "<>" --response + +-- OR -- + +how2validate -p NPM -s "NPM Access Token" -sec "<>" -r +``` + + +## API Reference + +Detailed documentation of the **How2Validate API** for both JavaScript and Python. + +### JavaScript API + +`validate(provider, service, secret, response, report)` + +Validates a secret against the specified provider and service. + +- **Parameters:** + - `provider` (string): The name of the provider (e.g., "NPM", "GitHub"). + - `service` (string): The specific service or token type. + - `secret` (string): The secret to validate. + - `response` (boolean): If `true`, returns the full response. + - `report` (string): Email Id to send a detailed report (Alpha Feature). + +- **Returns:** + - `validationResult` (object): An object containing the validation status and details. + +### Example + +```javascript +import { validate } from '@how2validate/how2validate'; + +const result = validate("NPM", "NPM Access Token", "<>", true/false, "useremail@domain.com"); +console.log(result); +``` + +### Python API + +`validate(provider, service, secret, response, report)` + +Validates a secret against the specified provider and service. + +- **Parameters:** + - `provider` (string): The name of the provider (e.g., "NPM", "GitHub"). + - `service` (string): The specific service or token type. + - `secret` (string): The secret to validate. + - `response` (boolean): If `true`, returns the full response. + - `report` (string): Email Id to send a detailed report (Alpha Feature). + +- **Returns:** + - `validation_result` (object): An object containing the validation status and details. + + +### Example + +```python +from how2validate import validate + +result = validate( + provider="NPM", + service="NPM Access Token", + secret="<>", + response=True/False, + report="useremail@domain.com" +) +print(result) +``` + +## License + +All `how2validate` packages are released under the MIT license. + diff --git a/assets/email-template-dev.html b/assets/email-template-dev.html new file mode 100644 index 0000000..a5466b3 --- /dev/null +++ b/assets/email-template-dev.html @@ -0,0 +1,203 @@ +
+ + + + + + + + +
+

How2Validate

+

A CLI tool to validate secrets for different services.

+
+ +

Validation Report

+
+
+

Secret Provider

+

Secret Service

+

Secret State

+

Secret Report

+
+
+

{{secret_provider}}

+

{{secret_service}}

+

{{secret_state}}

+
+
+

{{secret_report}}

+ + +
+ + +
+ + +
diff --git a/assets/email-template.html b/assets/email-template.html new file mode 100644 index 0000000..cedf64e --- /dev/null +++ b/assets/email-template.html @@ -0,0 +1,848 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/.eslintrc.json b/docs/.eslintrc.json new file mode 100644 index 0000000..b13bc4e --- /dev/null +++ b/docs/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript", + "plugin:tailwindcss/recommended" + ], + "rules": { + "no-console": ["error", { "allow": ["info", "warn", "error"] }], + "tailwindcss/classnames-order": "error", + "tailwindcss/no-custom-classname": ["warn"] + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js"], + "parser": "@typescript-eslint/parser" + } + ] +} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..c5faf72 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +# /dist/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/docs/.husky/post-process-generate-mdx.sh b/docs/.husky/post-process-generate-mdx.sh new file mode 100755 index 0000000..8aeba8f --- /dev/null +++ b/docs/.husky/post-process-generate-mdx.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +npx tsc --project tsconfig.scripts.json + +# 1. Compile the script +npx tsc scripts/generate-supported-secrets-table.ts --outDir dist/scripts/ + +# 2. Run the script (no renaming needed) +node dist/scripts/generate-supported-secrets-table.js || exit 1 \ No newline at end of file diff --git a/docs/.husky/post-process.sh b/docs/.husky/post-process.sh new file mode 100755 index 0000000..0fc63e9 --- /dev/null +++ b/docs/.husky/post-process.sh @@ -0,0 +1,26 @@ +#!/bin/sh + +npx tsc --project tsconfig.scripts.json + +for file in dist/scripts/**/*.js; do + mv "$file" "${file%.js}.mjs" +done + +for file in dist/scripts/scripts/content.mjs dist/scripts/lib/pageroutes.mjs; do + if [ -f "$file" ]; then + echo "Processing $file..." + + sed -i '' 's|import { Documents } from "@/settings/documents"|import { Documents } from "../settings/documents.mjs"|g' "$file" + + if [ $? -ne 0 ]; then + echo "Error: Failed to update $file" + exit 1 + fi + + echo "$file updated successfully." + else + echo "$file not found!" + fi +done + +node dist/scripts/scripts/content.mjs || exit 1 diff --git a/docs/.husky/pre-commit b/docs/.husky/pre-commit new file mode 100755 index 0000000..55bf0a5 --- /dev/null +++ b/docs/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh + +./.husky/post-process-generate-mdx.sh || exit 1 +./.husky/post-process.sh || exit 1 \ No newline at end of file diff --git a/docs/.prettierignore b/docs/.prettierignore new file mode 100644 index 0000000..e3cce11 --- /dev/null +++ b/docs/.prettierignore @@ -0,0 +1,5 @@ +node_modules +.next +dist +public/search-data +contents/docs \ No newline at end of file diff --git a/docs/app/acceptable-use-policy/[[...slug]]/page.tsx b/docs/app/acceptable-use-policy/[[...slug]]/page.tsx new file mode 100644 index 0000000..fa5fdd6 --- /dev/null +++ b/docs/app/acceptable-use-policy/[[...slug]]/page.tsx @@ -0,0 +1,99 @@ +import fs from "fs" +import path from "path" + +import { notFound } from "next/navigation" + +import { getContent } from "@/lib/markdown" +import { Settings } from "@/lib/meta" +import { PageRoutes } from "@/lib/pageroutes" +import { Separator } from "@/components/ui/separator" +import { Typography } from "@/components/ui/typography" +import { BackToTop } from "@/components/navigation/backtotop" +import Feedback from "@/components/navigation/feedback" +import PageBreadcrumb from "@/components/navigation/pagebreadcrumb" +import Pagination from "@/components/navigation/pagination" +import Toc from "@/components/navigation/toc" + +type PageProps = { + params: Promise<{ slug: string[] }> +} + +export default async function Pages({ params }: PageProps) { + const { slug = [] } = await params + const pathName = slug.join("/") + const res = await getContent("acceptable-use-policy", pathName) + + if (!res) notFound() + + const { frontmatter, content, tocs } = res + + return ( +
+
+ + + +

{frontmatter.title}

+

{frontmatter.description}

+ +
{content}
+ +
+
+ + {Settings.rightbar && ( + + )} +
+ ) +} + +export async function generateMetadata({ params }: PageProps) { + const { slug = [] } = await params + const pathName = slug.join("/") + const res = await getContent("acceptable-use-policy", pathName) + + if (!res) return null + + const { frontmatter, lastUpdated } = res + + return { + title: `${frontmatter.title} - ${Settings.title}`, + description: frontmatter.description, + keywords: frontmatter.keywords, + ...(lastUpdated && { + lastModified: new Date(lastUpdated).toISOString(), + }), + } +} + +export function generateStaticParams() { + return PageRoutes.filter((item) => { + if (!item.href) return false + // Only check acceptable-use-policy routes + if (!item.href.startsWith("/acceptable-use-policy/")) return false + // Remove the leading "/acceptable-use-policy/" + const slug = item.href.replace(/^\/acceptable-use-policy\//, "") + // Check if the file exists + const filePath = path.join( + process.cwd(), + "contents/acceptable-use-policy", + slug, + "index.mdx" + ) + return fs.existsSync(filePath) + }).map((item) => ({ + slug: item.href.split("/").slice(2), + })) +} diff --git a/docs/app/acceptable-use-policy/layout.tsx b/docs/app/acceptable-use-policy/layout.tsx new file mode 100644 index 0000000..09a17e1 --- /dev/null +++ b/docs/app/acceptable-use-policy/layout.tsx @@ -0,0 +1,11 @@ +export default function AcceptableUsePolicy({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( +
+
{children}
{" "} +
+ ) +} diff --git a/docs/app/api/auth/[...nextauth]/route.ts b/docs/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..11f5c3c --- /dev/null +++ b/docs/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,7 @@ +import NextAuth from "next-auth" + +import { authOptions } from "../authOptions" + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/docs/app/api/auth/authOptions.ts b/docs/app/api/auth/authOptions.ts new file mode 100644 index 0000000..cd845ae --- /dev/null +++ b/docs/app/api/auth/authOptions.ts @@ -0,0 +1,43 @@ +import User from "@/models/User" +import type { NextAuthOptions } from "next-auth" +import GitHubProvider from "next-auth/providers/github" + +import dbConnect from "@/lib/mongodb" + +export const authOptions: NextAuthOptions = { + providers: [ + GitHubProvider({ + clientId: process.env.GITHUB_ID!, + clientSecret: process.env.GITHUB_SECRET!, + }), + ], + callbacks: { + async session({ session, token }) { + if (session.user && token.sub) { + session.user.id = token.sub // token.sub contains the user ID + } + return session + }, + async signIn({ user }) { + await dbConnect() + + await User.findOneAndUpdate( + { id: user.id }, + { + $set: { + last_logged_in: Date.now(), + }, + $setOnInsert: { + id: user.id, + github_username: user.name, + github_email: user.email, + avatar_url: user.image, + }, + }, + { upsert: true } + ) + return true + }, + }, + secret: process.env.NEXTAUTH_SECRET, +} diff --git a/docs/app/api/check-api-threshold/route.ts b/docs/app/api/check-api-threshold/route.ts new file mode 100644 index 0000000..8036be9 --- /dev/null +++ b/docs/app/api/check-api-threshold/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server" + +import { isUserUnderApiThreshold } from "@/lib/api-utils" + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const userId = searchParams.get("userId") + + if (!userId) { + return NextResponse.json({ error: "Missing userId" }, { status: 400 }) + } + + const allowed = await isUserUnderApiThreshold(userId) + return NextResponse.json(allowed) +} diff --git a/docs/app/api/me/route.ts b/docs/app/api/me/route.ts new file mode 100644 index 0000000..3c16fac --- /dev/null +++ b/docs/app/api/me/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server" + +import { Token } from "@/types/pa-token" +import { + incrementTokenUsage, + updateTokenLastUsedAt, + validateUserToken, +} from "@/lib/api-utils" + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { status: 401, error: "Missing or invalid Authorization header" }, + { status: 401 } + ) + } + + const token = authHeader.replace("Bearer ", "").trim() + if (!token) { + return NextResponse.json( + { status: 400, error: "Token is required" }, + { status: 400 } + ) + } + // Validate the token + const validationRes = await validateUserToken(token) + + if (!validationRes) { + return NextResponse.json( + { + status: 403, + error: + "Invalid/ Expired API Token. See https://how2validate.vercel.app/apitoken for details.", + }, + { status: 403 } + ) + } + + if (!validationRes.token) { + return NextResponse.json( + { status: 400, error: "Token not found in validation result." }, + { status: 400 } + ) + } + + // Increment usage counts + if (validationRes.token?.token_hash) { + await incrementTokenUsage( + validationRes.userId, + validationRes.token.token_hash + ) + await updateTokenLastUsedAt( + validationRes.userId, + validationRes.token.token_hash + ) + } + + // Remove sensitive fields from the token object + // This is to ensure we don't expose sensitive information in the response + const safeTokenObj = validationRes.token as Token + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { token_hash, previous_hash, ...safeToken } = safeTokenObj + + return NextResponse.json({ + status: 200, + userId: validationRes.userId, + tokenId: validationRes.tokenId, + user: validationRes.user, + token: safeToken, + }) +} diff --git a/docs/app/api/public-key/route.ts b/docs/app/api/public-key/route.ts new file mode 100644 index 0000000..e8e0be5 --- /dev/null +++ b/docs/app/api/public-key/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server" + +import { + incrementTokenUsage, + updateTokenLastUsedAt, + validateUserToken, +} from "@/lib/api-utils" + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization") + const publicKey = process.env.PUBLIC_KEY + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { status: 401, error: "Missing or invalid Authorization header" }, + { status: 401 } + ) + } + + const token = authHeader.replace("Bearer ", "").trim() + if (!token) { + return NextResponse.json( + { status: 400, error: "Token is required" }, + { status: 400 } + ) + } + + // Use your utility to validate the token + const validationRes = await validateUserToken(token) + + if (!validationRes) { + return NextResponse.json( + { + status: 403, + error: + "Invalid API Token. See https://how2validate.vercel.app/apitoken for details.", + }, + { status: 403 } + ) + } + + if (!publicKey) { + return NextResponse.json( + { status: 500, error: "Public key not configured" }, + { status: 500 } + ) + } + + // Increment usage counts + if (validationRes.token?.token_hash) { + await incrementTokenUsage( + validationRes.userId, + validationRes.token.token_hash + ) + await updateTokenLastUsedAt( + validationRes.userId, + validationRes.token.token_hash + ) + } + + return NextResponse.json({ + status: 200, + key: publicKey, + }) +} diff --git a/docs/app/api/report/route.ts b/docs/app/api/report/route.ts new file mode 100644 index 0000000..56fb4df --- /dev/null +++ b/docs/app/api/report/route.ts @@ -0,0 +1,191 @@ +import crypto from "crypto" + +import { NextRequest, NextResponse } from "next/server" + +import { + incrementReportUsage, + incrementUserReportingCount, + isTokenUnderDailyReportThreshold, + validateUserToken, +} from "@/lib/api-utils" +import { sendEmail } from "@/lib/report-utils" + +export const config = { + api: { + bodyParser: { + sizeLimit: "5mb", + }, + }, +} + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { status: 401, error: "Missing or invalid Authorization header" }, + { status: 401 } + ) + } + + const token = authHeader.replace("Bearer ", "").trim() + if (!token) { + return NextResponse.json( + { status: 400, error: "Token is required" }, + { status: 400 } + ) + } + // Validate the token + const validationRes = await validateUserToken(token) + + if ( + !validationRes || + !validationRes.token || + !validationRes.token.token_hash + ) { + return NextResponse.json( + { + status: 403, + error: + "Invalid/ Expired API Token. See https://how2validate.vercel.app/apitoken for details.", + }, + { status: 403 } + ) + } + + // Check reporting threshold for this token + const underThreshold = await isTokenUnderDailyReportThreshold( + validationRes.userId, + validationRes.token.token_hash + ) + if (!underThreshold) { + return NextResponse.json( + { status: 429, error: "Daily report limit reached for this token." }, + { status: 429 } + ) + } + + // Increment usage counts + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let emailRes: any = null + let emailSuccess = false + if (validationRes.token?.token_hash && underThreshold) { + let reporting_data + try { + const body = await req.json() + const { encrypted_data } = body + const { key, iv, data } = encrypted_data + + const privateKeyPem = process.env.PRIVATE_KEY?.replace(/\\n/g, "\n") + if (!privateKeyPem) { + return NextResponse.json( + { status: 500, error: "Missing server private key" }, + { status: 500 } + ) + } + + const privateKey = crypto.createPrivateKey({ + key: privateKeyPem, + format: "pem", + }) + + const decryptedKey = crypto.privateDecrypt( + { + key: privateKey, + padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: "sha256", + }, + Buffer.from(key, "base64") + ) + + const decipher = crypto.createDecipheriv( + "aes-256-cbc", + decryptedKey, + Buffer.from(iv, "base64") + ) + + const aesDecrypted = Buffer.concat([ + decipher.update(Buffer.from(data, "base64")), + decipher.final(), // Handles PKCS#7 padding automatically + ]) + + let rawDecrypted = aesDecrypted.toString("utf-8") + + // Append username to the JSON string + try { + const toEmailAddress = validationRes.token.token_email + // Parse JSON, append username, and re-stringify + const tempData = JSON.parse(rawDecrypted) + tempData.email = toEmailAddress + rawDecrypted = JSON.stringify(tempData) + } catch { + return NextResponse.json( + { + status: 500, + error: "Invalid JSON in decrypted data or modification failed", + }, + { status: 500 } + ) + } + + // Parse JSON + try { + reporting_data = JSON.parse(rawDecrypted) + } catch { + return NextResponse.json( + { status: 500, error: "Invalid JSON in decrypted data" }, + { status: 500 } + ) + } + + // Process decrypted data + try { + emailRes = await sendEmail(reporting_data) + emailSuccess = true + } catch (e: unknown) { + let details = "Error sending report" + const errorMessage = + typeof e === "object" && + e !== null && + "message" in e && + typeof (e as { message?: string }).message === "string" + ? (e as { message: string }).message + : "" + if (errorMessage.trim()) { + try { + details = JSON.parse(errorMessage) + } catch { + details = errorMessage.trim() + } + } + return NextResponse.json( + { status: 500, error: "Failed to send email", details }, + { status: 500 } + ) + } + + await incrementReportUsage( + validationRes.userId, + validationRes.token.token_hash + ) + await incrementUserReportingCount(validationRes.userId) + } catch (err) { + let errorMsg = "Decryption failed" + if ( + typeof err === "object" && + err !== null && + "message" in err && + typeof (err as { message?: string }).message === "string" + ) { + errorMsg = `Decryption failed: ${(err as { message: string }).message}` + } + return NextResponse.json({ error: errorMsg }, { status: 500 }) + } + } + + return NextResponse.json({ + status: 200, + message: emailSuccess ? "Report sent successfully." : "Report not sent.", + response: emailRes && (await emailRes.json?.()), + }) +} diff --git a/docs/app/api/tokens/[userId]/route.ts b/docs/app/api/tokens/[userId]/route.ts new file mode 100644 index 0000000..0aa26d9 --- /dev/null +++ b/docs/app/api/tokens/[userId]/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from "next/server" +import TokenStore from "@/models/TokenStore" + +import { Token } from "@/types/pa-token" +import { + decrementUserActiveApiCount, + incrementUserActiveApiCount, +} from "@/lib/api-utils" +import dbConnect from "@/lib/mongodb" + +// CREATE a new token for a user (increments active count) +export async function POST( + req: NextRequest, + context: { params: Promise<{ userId: string }> } +) { + const { userId } = await context.params + const token: Token = await req.json() + + if (!userId || !token || !token.token_hash) { + return NextResponse.json( + { status: 400, error: "Missing userId or token_hash" }, + { status: 400 } + ) + } + + await dbConnect() + + // Try to update existing token in the array + const updated = await TokenStore.findOneAndUpdate( + { + user_id: userId, + "tokens.token_hash": token.previous_hash || token.token_hash, + }, + { + $set: { + "tokens.$": token, + }, + }, + { new: true } + ) + + // If not found, push as new token + if (!updated) { + const result = await TokenStore.findOneAndUpdate( + { user_id: userId }, + { $push: { tokens: token } }, + { upsert: true, new: true } + ) + await incrementUserActiveApiCount(userId) + return NextResponse.json(result) + } + + return NextResponse.json(updated) +} + +// UPDATE an existing token for a user (does NOT increment active count) +export async function PUT( + req: NextRequest, + context: { params: Promise<{ userId: string }> } +) { + const { userId } = await context.params + const token: Token = await req.json() + + if (!userId || !token || !token.token_hash) { + return NextResponse.json( + { status: 400, error: "Missing userId or token_hash" }, + { status: 400 } + ) + } + + await dbConnect() + + // Update existing token in the array (by previous_hash or token_hash) + const updated = await TokenStore.findOneAndUpdate( + { + user_id: userId, + "tokens.token_hash": token.previous_hash || token.token_hash, + }, + { + $set: { + "tokens.$": token, + }, + }, + { new: true } + ) + + if (!updated) { + return NextResponse.json( + { status: 404, error: "Token not found for update." }, + { status: 404 } + ) + } + + return NextResponse.json(updated) +} + +// GET all tokens for a user +export async function GET( + req: NextRequest, + context: { params: Promise<{ userId: string }> } +) { + const { userId } = await context.params + await dbConnect() + const result = await TokenStore.findOne({ user_id: userId }) + return NextResponse.json(result?.tokens || []) +} + +// DELETE a token by token_hash for a user +export async function DELETE( + req: NextRequest, + context: { params: Promise<{ userId: string }> } +) { + const { userId } = await context.params + const { token_hash } = await req.json() + + if (!userId || !token_hash) { + return NextResponse.json( + { status: 400, error: "Missing userId or token_hash" }, + { status: 400 } + ) + } + + await dbConnect() + + const userBefore = await TokenStore.findOne({ user_id: userId }) + const result = await TokenStore.findOneAndUpdate( + { user_id: userId }, + { $pull: { tokens: { token_hash } } }, + { new: true } + ) + + const tokenWasDeleted = + userBefore && + userBefore.tokens.some((t) => t.token_hash === token_hash) && + result && + result.tokens.length < userBefore.tokens.length + + if (tokenWasDeleted) { + await decrementUserActiveApiCount(userId) + } + + return NextResponse.json(result) +} diff --git a/docs/app/api/tokens/generatetoken/route.ts b/docs/app/api/tokens/generatetoken/route.ts new file mode 100644 index 0000000..2be4876 --- /dev/null +++ b/docs/app/api/tokens/generatetoken/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server" +import { getServerSession } from "next-auth" + +import { generateToken } from "@/lib/api-utils" + +import { authOptions } from "../../auth/authOptions" + +export async function POST() { + const session = await getServerSession(authOptions) + + if (!session || !session.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const result = await generateToken() + + return NextResponse.json(result) +} diff --git a/docs/app/api/tokens/route.ts b/docs/app/api/tokens/route.ts new file mode 100644 index 0000000..d014e62 --- /dev/null +++ b/docs/app/api/tokens/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server" +import TokenStoreModel from "@/models/TokenStore" + +// import Token from "@/models/Token" + +import connectToDB from "@/lib/mongodb" + +// GET /api/token?userId=123 +export async function GET(req: Request) { + await connectToDB() + + const { searchParams } = new URL(req.url) + const userId = searchParams.get("userId") + + if (!userId) { + return NextResponse.json( + { status: 400, error: "Missing userId" }, + { status: 400 } + ) + } + + const tokens = await TokenStoreModel.find({ user_id: userId }) + return NextResponse.json(tokens) +} diff --git a/docs/app/api/userinfo/route.ts b/docs/app/api/userinfo/route.ts new file mode 100644 index 0000000..13c0117 --- /dev/null +++ b/docs/app/api/userinfo/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server" +import TokenStore from "@/models/TokenStore" +import User from "@/models/User" + +import dbConnect from "@/lib/mongodb" + +export async function GET(req: NextRequest) { + const userId = req.nextUrl.searchParams.get("userId") + if (!userId) + return NextResponse.json( + { status: 400, error: "Missing userId" }, + { status: 400 } + ) + + await dbConnect() + const user = await User.findOne({ id: userId }) + if (!user) + return NextResponse.json( + { status: 404, error: "User not found" }, + { status: 404 } + ) + + // Fetch tokens and sum usage_count + const tokenStore = await TokenStore.findOne({ user_id: userId }) + const tokenCall = + tokenStore?.tokens?.reduce((sum, t) => sum + (t.usage_count ?? 0), 0) ?? 0 + + return NextResponse.json({ + name: user.name, + email: user.email, + image: user.avatar_url, + reportsSent: user.usage?.total_email_reported ?? 0, + maxReports: user.subscription?.email_per_day_threshold ?? 0, + tokenCall: tokenCall, + activeToken: user.usage?.active_api_count ?? 0, + maxTokens: user.subscription?.api_threshold ?? 0, + plan: user.subscription?.plan ?? "Pro-Free", + }) +} diff --git a/docs/app/api/validate/route.ts b/docs/app/api/validate/route.ts new file mode 100644 index 0000000..3135bbb --- /dev/null +++ b/docs/app/api/validate/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server" + +import { + incrementTokenUsage, + isTokenUnderDailyReportThreshold, + updateTokenLastUsedAt, + validateUserToken, +} from "@/lib/api-utils" + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization") + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { + status: 401, + error: "Missing or invalid Authorization header", + }, + { status: 401 } + ) + } + + const token = authHeader.replace("Bearer ", "").trim() + if (!token) { + return NextResponse.json({ error: "Token is required" }, { status: 400 }) + } + // Validate the token + const validationRes = await validateUserToken(token) + + if (!validationRes) { + return NextResponse.json( + { + status: 403, + error: + "Invalid/ Expired API Token. See https://how2validate.vercel.app/apitoken for details.", + }, + { status: 403 } + ) + } + + if (!validationRes.token) { + return NextResponse.json( + { + status: 400, + error: "Token not found in records.", + }, + { status: 400 } + ) + } + + // Check reporting threshold for this token + const underThreshold = await isTokenUnderDailyReportThreshold( + validationRes.userId, + validationRes.token.token_hash + ) + + // Increment usage counts + if (validationRes.token?.token_hash) { + await incrementTokenUsage( + validationRes.userId, + validationRes.token.token_hash + ) + await updateTokenLastUsedAt( + validationRes.userId, + validationRes.token.token_hash + ) + } + + return NextResponse.json({ + status: 200, + userId: validationRes.userId, + tokenId: validationRes.tokenId, + isTokenUnderDailyReportThreshold: underThreshold, + }) +} diff --git a/docs/app/apitoken/page.tsx b/docs/app/apitoken/page.tsx new file mode 100644 index 0000000..8febef7 --- /dev/null +++ b/docs/app/apitoken/page.tsx @@ -0,0 +1,548 @@ +"use client" + +import React, { ChangeEvent, FormEvent, useEffect, useState } from "react" +import { signIn, useSession } from "next-auth/react" +import { FaGithub } from "react-icons/fa" +import { TbEdit, TbRefresh, TbTrash } from "react-icons/tb" +import { toast } from "sonner" + +import { Token } from "@/types/pa-token" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { LoadingSpinner } from "@/components/ui/loading-spinner" +import UserOverview from "@/components/ui/overview" + +interface EditFormData { + token_email: string + token_name: string +} + +export default function TokenManager() { + const [dataLoaded, setDataLoaded] = useState(false) + const [tokens, setTokens] = useState([]) + const [showModal, setShowModal] = useState(false) + const [modalType, setModalType] = useState<"create" | "edit">("create") + const [formData, setFormData] = useState>({ + token_email: "", + token_name: "", + }) + const [editIndex, setEditIndex] = useState(null) + const [visibleTokenIndex, setVisibleTokenIndex] = useState( + null + ) + const { data: session, status } = useSession() + const { id: userId } = (session?.user as { + id: string + }) || { id: "" } + const [userInfo, setUserInfo] = useState({ + name: "", + email: "", + image: "", + reportsSent: 0, + maxReports: 0, + tokenCall: 0, + activeToken: 0, + maxTokens: 0, + plan: "Pro-Free", + }) + + // Fetch both user info and tokens, and set dataLoaded only when both are done + useEffect(() => { + if (!userId) return + // let tokensFetched = false + // let userInfoFetched = false + + fetch(`/api/userinfo?userId=${userId}`) + .then((res) => res.json()) + .then((data) => { + setUserInfo(data) + // userInfoFetched = true + }) + + fetch(`/api/tokens?userId=${userId}`) + .then((res) => res.json()) + .then((data) => { + setTokens(data[0]?.tokens || []) + // tokensFetched = true + setDataLoaded(true) + }) + .catch(() => { + // tokensFetched = true + setDataLoaded(true) + }) + }, [userId]) + + if (status === "unauthenticated") { + return ( +
+
+

+ Welcome Hacker 🥷🏻 +

+

+ +
+
+ ) + } + + const openModal = (type: "create" | "edit", index: number | null = null) => { + setModalType(type) + setShowModal(true) + if (type === "edit" && index !== null) { + const { token_email, token_name } = tokens[index] + setFormData({ token_email, token_name }) + setEditIndex(index) + } else { + setFormData({ token_email: "", token_name: "" }) + setEditIndex(null) + } + } + + const closeModal = () => { + setShowModal(false) + setFormData({ token_email: "", token_name: "" }) + setEditIndex(null) + } + + const handleCreateTokenClick = async () => { + const allowed = await fetch( + `/api/check-api-threshold?userId=${userId}` + ).then((res) => res.json()) + if (!allowed) { + toast.error( + "You can't create a new token. Delete an existing one or update its name/email to continue.", + { duration: 8000 } + ) + return + } + openModal("create") + } + + const generateToken = async () => { + const res = await fetch("/api/tokens/generatetoken", { method: "POST" }) + if (!res.ok) throw new Error("Failed to generate token") + return res.json() + } + + const handleChange = (e: ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }) + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + + // Normalize inputs for case-insensitive comparison + const inputEmail = formData.token_email.trim().toLowerCase() + const inputName = formData.token_name.trim().toLowerCase() + + const isDuplicate = tokens.some((token, index) => { + if (modalType === "edit" && index === editIndex) return false + return ( + token.token_email.trim().toLowerCase() === inputEmail || + token.token_name.trim().toLowerCase() === inputName + ) + }) + + if (isDuplicate) { + toast.error("Token name or email already taken.") + return + } + + const { token, tokenHash } = await generateToken() + + const newToken = { + token_name: formData.token_name, + one_time_token: token, + token_hash: tokenHash, + previous_hash: "", + token_email: formData.token_email, + usage_count: 0, + last_used_at: new Date(), + created_at: new Date(), + expires_at: new Date( + new Date().setFullYear(new Date().getFullYear() + 1) + ), + isActive: true, + usage: { + day: { api: 0, email: 0 }, + total: { api: 0, email: 0 }, + }, + } + + if (modalType === "create") { + setTokens([...tokens, newToken]) + await mutateToken(userId, newToken).then(() => { + toast.success("Token created successfully") + }) + setVisibleTokenIndex(tokens.length) + } else if (modalType === "edit" && editIndex !== null) { + const updatedTokens = [...tokens] + updatedTokens[editIndex] = { + ...tokens[editIndex], + ...formData, + token_hash: updatedTokens[editIndex].token_hash, + } + setTokens(updatedTokens) + await mutateToken(userId, updatedTokens[editIndex]).then(() => { + toast.success("Token updated successfully") + }) + } + + closeModal() + } + + const handleDelete = (index: number) => { + if (window.confirm("Are you sure you want to delete this token?")) { + setTokens(tokens.filter((_, i) => i !== index)) + deleteToken(userId, tokens[index].token_hash).then(() => { + toast.success("Token deleted successfully") + }) + if (visibleTokenIndex === index) setVisibleTokenIndex(null) + } + } + + const handleRegenerateToken = async (index: number) => { + // 1. Generate a new token + const { token, tokenHash } = await generateToken() + + // 2. Update the token at the given index + const updatedTokens = [...tokens] + const oldToken = updatedTokens[index] + + const newToken = { + ...oldToken, + one_time_token: token, + previous_hash: oldToken.token_hash, + token_hash: tokenHash, + last_used_at: new Date(), + created_at: new Date(), + } + + updatedTokens[index] = newToken + setTokens(updatedTokens) + + // 3. Persist the update to the server + await mutateToken(userId, newToken, "PUT").then(() => { + toast.success("Token regenerated successfully") + }) + + // 4. Show the new token for copying + setVisibleTokenIndex(index) + } + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + toast.success("Token copied to clipboard!") + }) + } + + const mutateToken = async ( + userId: string, + token: Token, + mode: "POST" | "PUT" | "DELETE" = "POST" + ) => { + const res = await fetch(`/api/tokens/${userId}`, { + method: mode, + body: JSON.stringify(token), + headers: { "Content-Type": "application/json" }, + }) + return res.json() + } + + const deleteToken = async (userId: string, token_hash: string) => { + const res = await fetch(`/api/tokens/${userId}`, { + method: "DELETE", + body: JSON.stringify({ token_hash }), + headers: { "Content-Type": "application/json" }, + }) + return res.json() + } + + return !dataLoaded ? ( + + ) : ( +
+ +
+

+ Personal Access Tokens +

+
+ +
+
+ +
+

+ Personal Access Tokens function like ordinary OAuth access tokens. + They are used by client tools for authenticating you and allowing you + to submit jobs. You can create as many tokens as you want and delete + tokens that are no longer needed. You will only see a newly-created + token until the first time you refresh the page.   + + We do not store tokens for security reasons; please make sure to + copy the token right away. + +

+
+ +
+ {/* Show table on medium+ screens */} +
+ + + + + + + + + + + {tokens.map((token, index) => ( + + + + + + + ))} + {tokens.length === 0 && ( + + + + )} + +
NameEmailTokenActions
{token.token_name}{token.token_email} + {visibleTokenIndex === index ? ( +
+
+ + {token.one_time_token} + + +
+
+ Make sure to copy the token now. You + won’t be able to see it again. +
+
+ ) : ( + + <token hidden> + + )} +
+ + + +
+ No tokens available. +
+
+ + {/* Card layout on small screens */} +
+ {tokens.length === 0 ? ( +
+ No tokens available. +
+ ) : ( + tokens.map((token, index) => ( +
+
+ Name: {token.token_name} +
+
+ Email: {token.token_email} +
+
+ Token:{" "} + {visibleTokenIndex === index ? ( + <> +
+ + {token.one_time_token} + + +
+
+ Make sure to copy the token now. You + won’t be able to see it again. +
+ + ) : ( + <token hidden> + )} +
+
+ + + +
+
+ )) + )} +
+
+ {showModal && ( +
+
+

+ {modalType === "create" ? "Create New Token" : "Edit Token"} +

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/docs/app/assets/fonts/CalSans-SemiBold.ttf b/docs/app/assets/fonts/CalSans-SemiBold.ttf new file mode 100644 index 0000000..4a2950a Binary files /dev/null and b/docs/app/assets/fonts/CalSans-SemiBold.ttf differ diff --git a/docs/app/assets/fonts/CalSans-SemiBold.woff b/docs/app/assets/fonts/CalSans-SemiBold.woff new file mode 100644 index 0000000..da45991 Binary files /dev/null and b/docs/app/assets/fonts/CalSans-SemiBold.woff differ diff --git a/docs/app/assets/fonts/CalSans-SemiBold.woff2 b/docs/app/assets/fonts/CalSans-SemiBold.woff2 new file mode 100644 index 0000000..36d71b7 Binary files /dev/null and b/docs/app/assets/fonts/CalSans-SemiBold.woff2 differ diff --git a/docs/app/assets/fonts/Inter-Bold.ttf b/docs/app/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..8e82c70 Binary files /dev/null and b/docs/app/assets/fonts/Inter-Bold.ttf differ diff --git a/docs/app/assets/fonts/Inter-Regular.ttf b/docs/app/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..8d4eebf Binary files /dev/null and b/docs/app/assets/fonts/Inter-Regular.ttf differ diff --git a/docs/app/blog/[[...slug]]/page.tsx b/docs/app/blog/[[...slug]]/page.tsx new file mode 100644 index 0000000..0ec29ad --- /dev/null +++ b/docs/app/blog/[[...slug]]/page.tsx @@ -0,0 +1,99 @@ +import fs from "fs" +import path from "path" + +import { notFound } from "next/navigation" + +import { getContent } from "@/lib/markdown" +import { Settings } from "@/lib/meta" +import { PageRoutes } from "@/lib/pageroutes" +import { Separator } from "@/components/ui/separator" +import { Typography } from "@/components/ui/typography" +import { BackToTop } from "@/components/navigation/backtotop" +import Feedback from "@/components/navigation/feedback" +import PageBreadcrumb from "@/components/navigation/pagebreadcrumb" +import Pagination from "@/components/navigation/pagination" +import Toc from "@/components/navigation/toc" + +type PageProps = { + params: Promise<{ slug: string[] }> +} + +export default async function Pages({ params }: PageProps) { + const { slug = [] } = await params + const pathName = slug.join("/") + const res = await getContent("blog", pathName) + + if (!res) notFound() + + const { frontmatter, content, tocs } = res + + return ( +
+
+ + + +

{frontmatter.title}

+

{frontmatter.description}

+ +
{content}
+ +
+
+ + {Settings.rightbar && ( + + )} +
+ ) +} + +export async function generateMetadata({ params }: PageProps) { + const { slug = [] } = await params + const pathName = slug.join("/") + const res = await getContent("blog", pathName) + + if (!res) return null + + const { frontmatter, lastUpdated } = res + + return { + title: `${frontmatter.title} - ${Settings.title}`, + description: frontmatter.description, + keywords: frontmatter.keywords, + ...(lastUpdated && { + lastModified: new Date(lastUpdated).toISOString(), + }), + } +} + +export function generateStaticParams() { + return PageRoutes.filter((item) => { + if (!item.href) return false + // Only check blog routes + if (!item.href.startsWith("/blog/")) return false + // Remove the leading "/blog/" + const slug = item.href.replace(/^\/blog\//, "") + // Check if the file exists + const filePath = path.join( + process.cwd(), + "contents/blog", + slug, + "index.mdx" + ) + return fs.existsSync(filePath) + }).map((item) => ({ + slug: item.href.split("/").slice(2), // slice(2) to remove ['', 'blog'] + })) +} diff --git a/docs/app/blog/layout.tsx b/docs/app/blog/layout.tsx new file mode 100644 index 0000000..4e1431c --- /dev/null +++ b/docs/app/blog/layout.tsx @@ -0,0 +1,14 @@ +import { Sidebar } from "@/components/navigation/sidebar" + +export default function Blog({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( +
+ +
{children}
{" "} +
+ ) +} diff --git a/docs/app/docs/[[...slug]]/page.tsx b/docs/app/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000..489c43e --- /dev/null +++ b/docs/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,99 @@ +import fs from "fs" +import path from "path" + +import { notFound } from "next/navigation" + +import { getContent } from "@/lib/markdown" +import { Settings } from "@/lib/meta" +import { PageRoutes } from "@/lib/pageroutes" +import { Separator } from "@/components/ui/separator" +import { Typography } from "@/components/ui/typography" +import { BackToTop } from "@/components/navigation/backtotop" +import Feedback from "@/components/navigation/feedback" +import PageBreadcrumb from "@/components/navigation/pagebreadcrumb" +import Pagination from "@/components/navigation/pagination" +import Toc from "@/components/navigation/toc" + +type PageProps = { + params: Promise<{ slug: string[] }> +} + +export default async function Pages({ params }: PageProps) { + const { slug = [] } = await params + const pathName = slug.join("/") + const res = await getContent("docs", pathName) + + if (!res) notFound() + + const { frontmatter, content, tocs } = res + + return ( +
+
+ + + +

{frontmatter.title}

+

{frontmatter.description}

+ +
{content}
+ +
+
+ + {Settings.rightbar && ( + + )} +
+ ) +} + +export async function generateMetadata({ params }: PageProps) { + const { slug = [] } = await params + const pathName = slug.join("/") + const res = await getContent("docs", pathName) + + if (!res) return null + + const { frontmatter, lastUpdated } = res + + return { + title: `${frontmatter.title} - ${Settings.title}`, + description: frontmatter.description, + keywords: frontmatter.keywords, + ...(lastUpdated && { + lastModified: new Date(lastUpdated).toISOString(), + }), + } +} + +export function generateStaticParams() { + return PageRoutes.filter((item) => { + if (!item.href) return false + // Only check docs routes + if (!item.href.startsWith("/docs/")) return false + // Remove the leading "/docs/" + const slug = item.href.replace(/^\/docs\//, "") + // Check if the file exists + const filePath = path.join( + process.cwd(), + "contents/docs", + slug, + "index.mdx" + ) + return fs.existsSync(filePath) + }).map((item) => ({ + slug: item.href.split("/").slice(2), + })) +} diff --git a/docs/app/docs/layout.tsx b/docs/app/docs/layout.tsx new file mode 100644 index 0000000..931a753 --- /dev/null +++ b/docs/app/docs/layout.tsx @@ -0,0 +1,14 @@ +import { Sidebar } from "@/components/navigation/sidebar" + +export default function Documents({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( +
+ +
{children}
{" "} +
+ ) +} diff --git a/docs/app/error.tsx b/docs/app/error.tsx new file mode 100644 index 0000000..d16023e --- /dev/null +++ b/docs/app/error.tsx @@ -0,0 +1,27 @@ +"use client" + +import { useEffect } from "react" + +import { Button } from "@/components/ui/button" + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + console.error(error) + }, [error]) + + return ( +
+
+

Oops!

+

Something went wrong!

+
+ +
+ ) +} diff --git a/docs/app/favicon.ico b/docs/app/favicon.ico new file mode 100644 index 0000000..2e620df Binary files /dev/null and b/docs/app/favicon.ico differ diff --git a/docs/app/layout.tsx b/docs/app/layout.tsx new file mode 100644 index 0000000..1b46e8d --- /dev/null +++ b/docs/app/layout.tsx @@ -0,0 +1,89 @@ +import type { Metadata } from "next" +import { + Inter as FontSans, + League_Spartan as LeagueSpartan, + Nunito_Sans as Nunito, +} from "next/font/google" +import localFont from "next/font/local" +import { GoogleTagManager } from "@next/third-parties/google" + +import { Settings } from "@/lib/meta" +import { Footer } from "@/components/navigation/footer" +import { Navbar } from "@/components/navigation/navbar" +import { Providers } from "@/components/providers" + +import "@/styles/globals.css" + +const fontSans = FontSans({ + subsets: ["latin"], + variable: "--font-sans", +}) + +const nunitoSans = Nunito({ + subsets: ["latin"], + variable: "--font-regular", +}) + +const leagueSpartan = LeagueSpartan({ + subsets: ["latin"], + variable: "--font-normal", +}) + +const fontHeading = localFont({ + src: "./assets/fonts/CalSans-SemiBold.woff2", + variable: "--font-heading", +}) + +const baseUrl = Settings.metadataBase + +export const metadata: Metadata = { + title: Settings.title, + metadataBase: new URL(baseUrl), + description: Settings.description, + keywords: Settings.keywords, + openGraph: { + type: Settings.openGraph.type, + url: baseUrl, + title: Settings.openGraph.title, + description: Settings.openGraph.description, + siteName: Settings.openGraph.siteName, + images: Settings.openGraph.images.map((image) => ({ + ...image, + url: `${baseUrl}${image.url}`, + })), + }, + twitter: { + card: Settings.twitter.card, + title: Settings.twitter.title, + description: Settings.twitter.description, + site: Settings.twitter.site, + images: Settings.twitter.images.map((image) => ({ + ...image, + url: `${baseUrl}${image.url}`, + })), + }, + alternates: { + canonical: baseUrl, + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {Settings.gtmconnected && } + + + +
{children}
+