commit bc9f96cbd4b27d101f65908b9b90b8c22b665b56 Author: Malin Date: Tue Feb 24 09:30:19 2026 +0100 feat: rebrand Hemmelig to paste.es for cloudhost.es - Set Spanish as default language with ephemeral/encrypted privacy focus - Translate all user-facing strings and legal pages to Spanish - Replace Norwegian flag with Spanish flag in footer - Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship - Rewrite PrivacyPage: zero data collection, ephemeral design emphasis - Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references - Update PWA manifest, HTML meta tags, package.json branding - Rename webhook headers to X-Paste-Event / X-Paste-Signature - Update API docs title and contact to paste.es / cloudhost.es Co-Authored-By: Claude Sonnet 4.6 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9706474 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules + +# Git +.git +.gitignore + +# GitHub +.github + +# Build artifacts +dist +*.tsbuildinfo + +# Logs and backups +*.log +example_request_do.log +hemmelig.backup.db + +# Media and docs +*.mp4 +desktop.gif +desktop.png +banner.png +logo.png +logo.svg +logo_color.png +docs/ +README.md +LICENSE +CLAUDE.md +GEMINI.md + +# Dev files +bin/ +.env* +.eslintcache +*.local +helm/ + +# Generated +prisma/generated/* + +# Uploads (runtime data) +uploads/ + +# IDE +.vscode +.idea +*.swp +*.swo + +# Test files +api/tests/ +*.test.ts +*.spec.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f1fb322 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +[*] +indent_style = space +indent_size = 4 + +[*.json] +indent_size = 2 + +[*.yml] +indent_size = 2 \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..516ca5e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @bjarneo diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..0e451c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,25 @@ +name: 🐛 Bug +description: Report an issue to help improve the project. +labels: ["bug"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A brief description of the issue, also include what you tried and what didn't work + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Please add screenshots if applicable + validations: + required: false + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this bug? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a49eab2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 0000000..bec9157 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,26 @@ +name: 📄 Documentation issue +description: Found an issue in the documentation? +title: "[DOCS] " +labels: ["documentation"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A brief description of the issue, also include what you tried and what didn't work + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Please add screenshots if applicable + validations: + required: false + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this issue? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..e630cd6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: 💡Feature Request +description: Have a new idea/feature? Please suggest! +title: "[FEATURE] " +labels: + ["enhancement"] +body: + - type: textarea + id: description + attributes: + label: Description + description: A brief description of the enhancement you propose, also include what you tried and what worked. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Please add screenshots if applicable + validations: + required: false + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this idea? + validations: + required: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/other.yml b/.github/ISSUE_TEMPLATE/other.yml new file mode 100644 index 0000000..2dba8a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.yml @@ -0,0 +1,22 @@ +name: Other +description: Use this for any other issues. Avoid creating blank issues +title: "[OTHER]" + +body: + - type: markdown + attributes: + value: "# Other issue" + - type: textarea + id: issuedescription + attributes: + label: What would you like to share? + description: Provide a clear and concise explanation of your issue. + validations: + required: true + - type: textarea + id: extrainfo + attributes: + label: Additional information + description: Is there anything else we should know about this issue? + validations: + required: false \ No newline at end of file diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000..5431226 --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,168 @@ +name: CLI Release + +on: + push: + tags: + - 'cli-v*' + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 1.0.0)' + required: true + type: string + +permissions: + contents: write + +jobs: + build: + name: Build CLI + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + suffix: linux-amd64 + - goos: linux + goarch: arm64 + suffix: linux-arm64 + - goos: darwin + goarch: amd64 + suffix: darwin-amd64 + - goos: darwin + goarch: arm64 + suffix: darwin-arm64 + - goos: windows + goarch: amd64 + suffix: windows-amd64.exe + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binary + working-directory: cli-go + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + go build -ldflags="-s -w" -o hemmelig-${{ matrix.suffix }} . + + - name: Generate SHA256 + working-directory: cli-go + run: | + sha256sum hemmelig-${{ matrix.suffix }} > hemmelig-${{ matrix.suffix }}.sha256 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: hemmelig-${{ matrix.suffix }} + path: | + cli-go/hemmelig-${{ matrix.suffix }} + cli-go/hemmelig-${{ matrix.suffix }}.sha256 + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir -p release + find artifacts -type f -exec cp {} release/ \; + ls -la release/ + + - name: Generate checksums file + working-directory: release + run: | + cat *.sha256 > checksums.txt + cat checksums.txt + + - name: Get version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF#refs/tags/cli-v}" >> $GITHUB_OUTPUT + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: cli-v${{ steps.version.outputs.version }} + name: Hemmelig CLI v${{ steps.version.outputs.version }} + draft: false + prerelease: false + files: | + release/hemmelig-* + release/checksums.txt + body: | + ## Hemmelig CLI v${{ steps.version.outputs.version }} + + Create encrypted, self-destructing secrets from the command line. + + ### Installation + + #### Linux (amd64) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-linux-amd64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### Linux (arm64) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-linux-arm64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### macOS (Apple Silicon) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-darwin-arm64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### macOS (Intel) + ```bash + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/hemmelig-darwin-amd64 -o hemmelig + chmod +x hemmelig + sudo mv hemmelig /usr/local/bin/ + ``` + + #### Windows + Download `hemmelig-windows-amd64.exe` and add it to your PATH. + + ### Verify Download + + ```bash + # Download checksums + curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${{ steps.version.outputs.version }}/checksums.txt -o checksums.txt + + # Verify (Linux/macOS) + sha256sum -c checksums.txt --ignore-missing + ``` + + ### Usage + + ```bash + hemmelig "my secret message" + hemmelig "my secret" -t "Title" -e 7d -v 3 + cat file.txt | hemmelig + ``` + + See the [CLI documentation](https://github.com/HemmeligOrg/Hemmelig.app/blob/main/docs/cli.md) for more options. diff --git a/.github/workflows/publish_docker_image.yaml b/.github/workflows/publish_docker_image.yaml new file mode 100644 index 0000000..b69a0c1 --- /dev/null +++ b/.github/workflows/publish_docker_image.yaml @@ -0,0 +1,59 @@ +name: Publish Docker image +on: + release: + types: [published] +jobs: + push_to_registry: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get latest git tag + id: latest_tag + uses: 'WyriHaximus/github-action-get-previous-tag@v1' + with: + fallback: no-tag + + - name: Get short SHA + id: short_sha + run: echo "sha=$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Get major version + id: major_version + run: | + echo "version=$(echo ${{ steps.latest_tag.outputs.tag }} | cut -d'.' -f1)" >> $GITHUB_OUTPUT + + - name: Get minor version + id: minor_version + run: | + echo "version=$(echo ${{ steps.latest_tag.outputs.tag }} | cut -d'.' -f1,2)" >> $GITHUB_OUTPUT + + - name: Build and push multi-arch image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + hemmeligapp/hemmelig:${{ steps.latest_tag.outputs.tag }} + hemmeligapp/hemmelig:${{ steps.minor_version.outputs.version }} + hemmeligapp/hemmelig:${{ steps.major_version.outputs.version }} + build-args: | + GIT_SHA=${{ steps.short_sha.outputs.sha }} + GIT_TAG=${{ steps.latest_tag.outputs.tag }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2d6c3c2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +name: Generate Release Notes + +on: + release: + types: [created] + +jobs: + update-release-notes: + name: Update Release with Commit Notes + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Check out the repo + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get previous tag + id: prev_tag + run: | + # Get all tags sorted by version, exclude the current tag, take the first result + CURRENT_TAG="${{ github.event.release.tag_name }}" + PREV_TAG=$(git tag --sort=-version:refname | grep -v "^${CURRENT_TAG}$" | head -n 1) + + if [ -z "$PREV_TAG" ]; then + echo "No previous tag found, will use initial commit" + # Use the initial commit as the starting point + PREV_TAG=$(git rev-list --max-parents=0 HEAD) + fi + + echo "Previous tag/commit: $PREV_TAG" + echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT + + - name: Generate commit list + id: commits + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO_URL="https://github.com/${{ github.repository }}" + CURRENT_TAG="${{ github.event.release.tag_name }}" + PREV_TAG="${{ steps.prev_tag.outputs.tag }}" + + # Always use range format - PREV_TAG is guaranteed to be set (either previous tag or initial commit) + RANGE="${PREV_TAG}..${CURRENT_TAG}" + + echo "Generating commits for range: $RANGE" + + # Generate commit list with format: title - @nickname - sha with link + # Using tformat instead of format to ensure trailing newline + COMMITS="" + while IFS='|' read -r SHA SHORT_SHA TITLE AUTHOR || [ -n "$SHA" ]; do + [ -z "$SHA" ] && continue + + # Try to get GitHub username from commit API + USERNAME=$(gh api "repos/${{ github.repository }}/commits/${SHA}" --jq '.author.login' 2>/dev/null || echo "") + + if [ -n "$USERNAME" ]; then + AUTHOR_INFO="@${USERNAME}" + else + AUTHOR_INFO="${AUTHOR}" + fi + + # Check if this is a PR merge commit + if [[ "$TITLE" =~ ^Merge\ pull\ request\ \#([0-9]+) ]]; then + PR_NUMBER="${BASH_REMATCH[1]}" + # Get PR title + PR_TITLE=$(gh pr view "$PR_NUMBER" --json title --jq '.title' 2>/dev/null || echo "$TITLE") + COMMITS="${COMMITS}- ${PR_TITLE} (#${PR_NUMBER}) by ${AUTHOR_INFO} ([\`${SHORT_SHA}\`](${REPO_URL}/commit/${SHA}))"$'\n' + else + COMMITS="${COMMITS}- ${TITLE} by ${AUTHOR_INFO} ([\`${SHORT_SHA}\`](${REPO_URL}/commit/${SHA}))"$'\n' + fi + done < <(git log "${RANGE}" --pretty=tformat:"%H|%h|%s|%an") + + # Handle multiline output + { + echo "list<> $GITHUB_OUTPUT + + - name: Update Release Notes + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.release.tag_name }} + body: | + ## What's Changed + + ${{ steps.commits.outputs.list }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ github.event.release.tag_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + update-dockerhub-description: + name: Update Docker Hub Description + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Update Docker Hub description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + repository: hemmeligapp/hemmelig + readme-filepath: ./docs/docker.md diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml new file mode 100644 index 0000000..c8bf907 --- /dev/null +++ b/.github/workflows/trivy.yaml @@ -0,0 +1,51 @@ +name: Trivy - Scan +on: + schedule: + # https://crontab.guru/daily + - cron: '0 0 * * *' + pull_request: +jobs: + scan_repository: + name: Scan the repository + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner in repo mode + uses: aquasecurity/trivy-action@0.28.0 + with: + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' + + scan_vulnerabilities: + name: Scan the docker image + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build an image from Dockerfile + run: | + docker build -t docker.io/hemmeligorg/hemmelig:${{ github.sha }} . + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: 'docker.io/hemmeligorg/hemmelig:${{ github.sha }}' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: 'trivy-results.sarif' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37d914a --- /dev/null +++ b/.gitignore @@ -0,0 +1,139 @@ +.env +cli/node_modules +cli/dist +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.env.private +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# OS generated files +.DS_Store +.Trashes +ehthumbs.db +Thumbs.db + +build/ + +hemmelig.yaml + +size-plugin.json + +.env.local + +hemmelig.db* +uploads/ +prisma_test.js +database/ +data/ +.vscode +hemmelig.backup.db +client/build/ +api/**/*.js +api/*.js +.idea +prisma/generated/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..487263b --- /dev/null +++ b/.npmignore @@ -0,0 +1,9 @@ +src/client +src/server +src/*.js +server.js +config/ +public/ +.github/ +tests/ +hemmelig.backup.db \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..2a0f707 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +dist +build +.husky +bun.lock +package-lock.json +*.min.js +*.min.css +prisma/migrations +.github +helm/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e634b77 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": true, + "singleQuote": true, + "arrowParens": "always", + "jsxSingleQuote": false, + "printWidth": 100, + "plugins": ["prettier-plugin-prisma", "prettier-plugin-organize-imports"] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d796a6a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,640 @@ +# Claude AI Assistant Guidelines for Hemmelig.app + +Welcome to Hemmelig.app! This guide will help you navigate our codebase and contribute effectively. Think of this document as your onboarding buddy - it covers everything you need to know to maintain code quality, security, and architectural consistency. + +## What is Hemmelig? + +Hemmelig.app is a secure secret-sharing application that lets users share encrypted messages that automatically self-destruct after being read. The name "Hemmelig" means "secret" in Norwegian - fitting, right? + +### The Security Model You Must Understand + +**CRITICAL: Zero-Knowledge Architecture** + +This is the heart of Hemmelig. Before you write a single line of code, make sure you understand this: + +- All encryption/decryption happens **client-side only** using the Web Crypto API +- The server **never** sees plaintext secrets - only encrypted blobs +- Decryption keys live in URL fragments (`#decryptionKey=...`), which browsers **never send to servers** +- This is our fundamental security promise to users - **do not compromise this under any circumstances** + +### How Encryption Works + +| Component | Details | +| ------------------ | ---------------------------------------------------------- | +| **Algorithm** | AES-256-GCM (authenticated encryption) | +| **Key Derivation** | PBKDF2 with SHA-256, 1,300,000 iterations | +| **IV** | 96-bit random initialization vector per encryption | +| **Salt** | 32-character random string per secret (stored server-side) | +| **Implementation** | `src/lib/crypto.ts` | + +## Technology Stack + +Here's what powers Hemmelig: + +| Layer | Technology | Notes | +| -------------- | ---------------------------- | ------------------------------------------ | +| **Runtime** | Node.js 25 | JavaScript runtime for dev and production | +| **Frontend** | React 19 + Vite + TypeScript | All components use `.tsx` | +| **Backend** | Hono (RPC mode) | Type-safe API client generation | +| **Database** | SQLite + Prisma ORM | Schema in `prisma/schema.prisma` | +| **Styling** | Tailwind CSS v4 | Class-based, light/dark mode support | +| **State** | Zustand | Lightweight state management | +| **Auth** | better-auth | Session-based with 2FA support | +| **i18n** | react-i18next | All user-facing strings must be translated | +| **Monitoring** | prom-client | Prometheus metrics | +| **API Docs** | Swagger UI | OpenAPI documentation | + +## Project Structure + +Here's how the codebase is organized: + +``` +hemmelig.app/ +├── api/ # Backend (Hono) +│ ├── app.ts # Main Hono application setup +│ ├── auth.ts # Authentication configuration +│ ├── config.ts # Application configuration +│ ├── openapi.ts # OpenAPI/Swagger spec & UI +│ ├── routes.ts # Route aggregator +│ ├── routes/ # Individual route handlers +│ │ ├── secrets.ts # Secret CRUD operations +│ │ ├── secret-requests.ts # Secret request management +│ │ ├── account.ts # User account management +│ │ ├── files.ts # File upload/download +│ │ ├── user.ts # User management (admin) +│ │ ├── instance.ts # Instance settings +│ │ ├── analytics.ts # Usage analytics +│ │ ├── invites.ts # Invite code management +│ │ ├── api-keys.ts # API key management +│ │ ├── setup.ts # Initial setup flow +│ │ ├── health.ts # Health check endpoints +│ │ └── metrics.ts # Prometheus metrics +│ ├── lib/ # Backend utilities +│ │ ├── db.ts # Prisma client singleton +│ │ ├── password.ts # Password hashing (Argon2) +│ │ ├── files.ts # File handling utilities +│ │ ├── settings.ts # Instance settings helper +│ │ ├── webhook.ts # Webhook dispatch utilities +│ │ ├── analytics.ts # Analytics utilities +│ │ ├── constants.ts # Shared constants +│ │ └── utils.ts # General utilities +│ ├── middlewares/ # Hono middlewares +│ │ ├── auth.ts # Authentication middleware +│ │ ├── ratelimit.ts # Rate limiting +│ │ └── ip-restriction.ts # IP allowlist/blocklist +│ ├── validations/ # Zod schemas for request validation +│ └── jobs/ # Background jobs (cleanup, etc.) +├── src/ # Frontend (React) +│ ├── components/ # Reusable UI components +│ │ ├── Layout/ # Layout wrappers +│ │ ├── Editor.tsx # TipTap rich text editor +│ │ └── ... +│ ├── pages/ # Route-level components +│ │ ├── HomePage.tsx +│ │ ├── SecretPage.tsx +│ │ ├── SetupPage.tsx # Initial admin setup +│ │ ├── Verify2FAPage.tsx # Two-factor verification +│ │ ├── Dashboard/ # Admin dashboard pages +│ │ └── ... +│ ├── store/ # Zustand stores +│ │ ├── secretStore.ts # Secret creation state +│ │ ├── userStore.ts # Current user state +│ │ ├── hemmeligStore.ts # Instance settings +│ │ ├── themeStore.ts # Light/dark mode (persisted) +│ │ └── ... +│ ├── lib/ # Frontend utilities +│ │ ├── api.ts # Hono RPC client +│ │ ├── auth.ts # better-auth client +│ │ ├── crypto.ts # Client-side encryption +│ │ ├── hash.ts # Hashing utilities +│ │ └── analytics.ts # Page view tracking +│ ├── i18n/ # Internationalization +│ │ └── locales/ # Translation JSON files +│ └── router.tsx # React Router configuration +├── prisma/ +│ └── schema.prisma # Database schema +├── scripts/ # Utility scripts +│ ├── admin.ts # Set user as admin +│ └── seed-demo.ts # Seed demo data +├── server.ts # Production server entry point +└── vite.config.ts # Vite configuration +``` + +## Getting Started + +### Development Commands + +```bash +# Install dependencies +npm install + +# Start frontend with hot reload +npm run dev + +# Start API server (runs migrations automatically) +npm run dev:api + +# Build for production +npm run build + +# Run production server +npm run start + +# Database commands +npm run migrate:dev # Create and apply migrations +npm run migrate:deploy # Apply pending migrations (production) +npm run migrate:reset # Reset database (destructive!) +npm run migrate:status # Check migration status + +# Utility scripts +npm run set:admin # Promote a user to admin +npm run seed:demo # Seed database with demo data + +# Code quality +npm run format # Format code with Prettier +npm run format:check # Check formatting +npm run test:e2e # Run end-to-end tests with Playwright +``` + +## Coding Guidelines + +### Core Principles + +1. **Make Surgical Changes** - Only change what's necessary. Don't refactor, optimize, or "improve" unrelated code. + +2. **Follow Existing Patterns** - Consistency trumps personal preference. Match what's already in the codebase. + +3. **Ask Before Adding Dependencies** - Never add, remove, or update packages without explicit permission. + +4. **Security First** - Extra scrutiny for anything touching encryption, authentication, or data handling. + +--- + +## Frontend Guidelines + +### Component Structure + +Keep your components clean and consistent: + +```tsx +// Use functional components with hooks +export function MyComponent({ prop1, prop2 }: MyComponentProps) { + const { t } = useTranslation(); // Always use i18n for user-facing text + const [state, setState] = useState(initialValue); + + const handleAction = () => { + // Event handler logic + }; + + return
{/* Always support light/dark mode */}
; +} +``` + +### Design System + +Our UI follows these principles: + +- **Compact design** with minimal padding +- **Sharp corners** - no `rounded-*` classes +- **Light and dark mode** support is mandatory +- **Mobile-first** responsive design + +### Styling with Tailwind + +```tsx +// GOOD: Light mode first, then dark variant, sharp corners +className = + 'bg-white dark:bg-dark-800 text-gray-900 dark:text-white border border-gray-200 dark:border-dark-600'; + +// BAD: Missing light mode variant +className = 'dark:bg-dark-800'; + +// BAD: Using rounded corners +className = 'rounded-lg'; + +// BAD: Using arbitrary values when design tokens exist +className = 'bg-[#111111]'; // Use bg-dark-800 instead +``` + +### Custom Color Palette + +```javascript +// tailwind.config.js defines these colors: +dark: { + 900: '#0a0a0a', // Darkest background + 800: '#111111', // Card backgrounds + 700: '#1a1a1a', // Input backgrounds + 600: '#222222', // Borders + 500: '#2a2a2a', // Lighter borders +} +light: { + 900: '#ffffff', + 800: '#f8fafc', + 700: '#f1f5f9', + 600: '#e2e8f0', + 500: '#cbd5e1', +} +``` + +### State Management with Zustand + +```typescript +// src/store/exampleStore.ts +import { create } from 'zustand'; + +interface ExampleState { + data: string; + setData: (data: string) => void; +} + +export const useExampleStore = create((set) => ({ + data: '', + setData: (data) => set({ data }), +})); +``` + +### API Calls + +Always use the typed Hono RPC client - it gives you full type safety: + +```typescript +import { api } from '../lib/api'; + +// The client is fully typed based on backend routes +const response = await api.secrets.$post({ + json: { secret: encryptedData, expiresAt: timestamp }, +}); +const data = await response.json(); +``` + +### React Router Loaders + +We use React Router v7 with data loaders. Here's the pattern: + +```typescript +// In router.tsx +{ + path: '/dashboard/secrets', + element: , + loader: async () => { + const res = await api.secrets.$get(); + return await res.json(); + }, +} + +// In the component +import { useLoaderData } from 'react-router-dom'; + +export function SecretsPage() { + const secrets = useLoaderData(); + // Use the pre-loaded data +} +``` + +--- + +## Internationalization (i18n) + +### The Golden Rule + +**When you add or modify any user-facing string, you MUST update ALL translation files.** + +### Supported Languages + +We currently support these languages: + +| Language | File Path | +| --------- | ----------------------------- | +| English | `src/i18n/locales/en/en.json` | +| Danish | `src/i18n/locales/da/da.json` | +| German | `src/i18n/locales/de/de.json` | +| Spanish | `src/i18n/locales/es/es.json` | +| French | `src/i18n/locales/fr/fr.json` | +| Italian | `src/i18n/locales/it/it.json` | +| Dutch | `src/i18n/locales/nl/nl.json` | +| Norwegian | `src/i18n/locales/no/no.json` | +| Swedish | `src/i18n/locales/sv/sv.json` | +| Chinese | `src/i18n/locales/zh/zh.json` | + +### How to Add New Strings + +1. **Add to English first** - `src/i18n/locales/en/en.json` +2. **Add to ALL other locale files** - Even if you use English as a placeholder, add the key to every file +3. **Use nested keys** - Follow the existing structure (e.g., `secret_page.loading_message`) + +```tsx +// Using translations in components +const { t } = useTranslation(); + +// GOOD +

{t('secret_page.loading_message')}

+ +// BAD: Hardcoded string +

Loading...

+``` + +### Translation Checklist + +Before committing, verify: + +- [ ] Added key to `en/en.json` with proper English text +- [ ] Added key to `da/da.json` +- [ ] Added key to `de/de.json` +- [ ] Added key to `es/es.json` +- [ ] Added key to `fr/fr.json` +- [ ] Added key to `it/it.json` +- [ ] Added key to `nl/nl.json` +- [ ] Added key to `no/no.json` +- [ ] Added key to `sv/sv.json` +- [ ] Added key to `zh/zh.json` + +> **Tip**: If you don't know the translation, use the English text as a placeholder and add a `// TODO: translate` comment in your PR description. + +--- + +## Backend Guidelines + +### Route Structure + +```typescript +// api/routes/example.ts +import { Hono } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; + +const exampleRoute = new Hono().post( + '/', + zValidator( + 'json', + z.object({ + field: z.string().min(1).max(1000), + }) + ), + async (c) => { + const { field } = c.req.valid('json'); + // Handler logic here + return c.json({ success: true }); + } +); + +export default exampleRoute; +``` + +### Database Operations + +```typescript +// Always use the Prisma client from api/lib/db.ts +import prisma from '../lib/db'; + +// Use transactions for multiple operations +await prisma.$transaction([ + prisma.secrets.create({ data: { ... } }), + prisma.file.createMany({ data: files }), +]); +``` + +### Input Validation + +- **Always** validate input using Zod schemas +- Place reusable schemas in `api/validations/` +- Validate at the route level using `zValidator` + +### Error Handling + +```typescript +// Return consistent error responses +return c.json({ error: 'Descriptive error message' }, 400); + +// Zod automatically handles validation errors +``` + +### Database Schema Changes + +1. Modify `prisma/schema.prisma` +2. Run `npm run migrate:dev --name descriptive_name` +3. Test the migration locally +4. Commit both schema and migration files + +--- + +## Health & Monitoring + +### Health Endpoints + +Hemmelig provides Kubernetes-ready health checks: + +| Endpoint | Purpose | Checks | +| ------------------- | --------------- | ------------------------- | +| `GET /health/live` | Liveness probe | Process is running | +| `GET /health/ready` | Readiness probe | Database, storage, memory | + +### Metrics Endpoint + +Prometheus metrics are available at `GET /metrics`. This endpoint can be optionally protected with authentication. + +--- + +## Security Checklist + +When modifying security-sensitive code, verify: + +- [ ] Encryption/decryption remains client-side only +- [ ] Decryption keys never reach the server +- [ ] Input validation is present on all endpoints +- [ ] Authentication checks are in place where required +- [ ] Rate limiting is applied to sensitive endpoints +- [ ] No sensitive data in logs or error messages +- [ ] File uploads are validated and sanitized + +--- + +## Testing + +- End-to-end tests use [Playwright](https://playwright.dev/) - find them in `tests/e2e/` +- Run e2e tests with `npm run test:e2e` +- When adding new features, add corresponding test files + +--- + +## Common Patterns + +### Creating a New Page + +1. Create component in `src/pages/` +2. Add route with loader in `src/router.tsx` +3. **Add translations to ALL locale files** +4. Ensure light/dark mode support + +### Creating a New API Endpoint + +1. Add route handler in `api/routes/` +2. Register in `api/routes.ts` +3. Add Zod validation schema +4. Frontend types update automatically via Hono RPC + +### Adding a New Store + +1. Create store in `src/store/` +2. Follow existing Zustand patterns +3. Export from the store file + +--- + +## What NOT to Do + +These are hard rules - no exceptions: + +1. **Never** modify encryption logic without explicit approval +2. **Never** log or store plaintext secrets server-side +3. **Never** send decryption keys to the server +4. **Never** bypass input validation +5. **Never** add dependencies without approval +6. **Never** modify unrelated code "while you're in there" +7. **Never** use `any` types in TypeScript +8. **Never** commit `.env` files or secrets +9. **Never** disable security features "temporarily" +10. **Never** add user-facing strings without updating ALL translation files + +--- + +## Quick Reference + +### Key File Locations + +| What | Where | +| ---------------------- | ---------------------- | +| Frontend components | `src/components/` | +| Page components | `src/pages/` | +| API routes | `api/routes/` | +| API config | `api/config.ts` | +| Database schema | `prisma/schema.prisma` | +| Translations | `src/i18n/locales/` | +| Stores | `src/store/` | +| Client-side encryption | `src/lib/crypto.ts` | +| API client | `src/lib/api.ts` | +| Backend setup | `api/app.ts` | +| Design tokens | `tailwind.config.js` | + +### Environment Variables + +```bash +# Required +DATABASE_URL= # SQLite connection (file:./data/hemmelig.db) +BETTER_AUTH_SECRET= # Auth secret key (generate a random string) +BETTER_AUTH_URL= # Public URL of your instance + +# Optional - Server +HEMMELIG_PORT= # Port the server listens on (default: 3000) +HEMMELIG_BASE_URL= # Public URL (required for OAuth callbacks) +HEMMELIG_TRUSTED_ORIGIN= # Additional trusted origin for CORS + +# Optional - Analytics +HEMMELIG_ANALYTICS_ENABLED= # Enable/disable analytics tracking (default: true) +HEMMELIG_ANALYTICS_HMAC_SECRET= # HMAC secret for anonymizing visitor IDs + +# Optional - Managed Mode (all instance settings via env vars) +HEMMELIG_MANAGED= # Enable managed mode (true/false) +``` + +See `docs/env.md` for the full environment variable reference. + +--- + +## Managed Mode + +When `HEMMELIG_MANAGED=true`, all instance settings are controlled via environment variables instead of the database. The admin dashboard becomes read-only. + +### Managed Mode Environment Variables + +| Variable | Description | Default | +| ---------------------------------------- | ------------------------------------------- | ------- | +| `HEMMELIG_MANAGED` | Enable managed mode | `false` | +| `HEMMELIG_INSTANCE_NAME` | Display name for your instance | `""` | +| `HEMMELIG_INSTANCE_DESCRIPTION` | Description shown on the homepage | `""` | +| `HEMMELIG_INSTANCE_LOGO` | Base64-encoded logo image (max 512KB) | `""` | +| `HEMMELIG_ALLOW_REGISTRATION` | Allow new user signups | `true` | +| `HEMMELIG_REQUIRE_EMAIL_VERIFICATION` | Require email verification | `false` | +| `HEMMELIG_DEFAULT_SECRET_EXPIRATION` | Default expiration in hours | `72` | +| `HEMMELIG_MAX_SECRET_SIZE` | Max secret size in KB | `1024` | +| `HEMMELIG_IMPORTANT_MESSAGE` | Alert banner shown to all users | `""` | +| `HEMMELIG_ALLOW_PASSWORD_PROTECTION` | Allow password-protected secrets | `true` | +| `HEMMELIG_ALLOW_IP_RESTRICTION` | Allow IP range restrictions | `true` | +| `HEMMELIG_ALLOW_FILE_UPLOADS` | Allow users to attach files | `true` | +| `HEMMELIG_ENABLE_RATE_LIMITING` | Enable API rate limiting | `true` | +| `HEMMELIG_RATE_LIMIT_REQUESTS` | Max requests per window | `100` | +| `HEMMELIG_RATE_LIMIT_WINDOW` | Rate limit window in seconds | `60` | +| `HEMMELIG_REQUIRE_INVITE_CODE` | Require invite code for registration | `false` | +| `HEMMELIG_ALLOWED_EMAIL_DOMAINS` | Comma-separated allowed domains | `""` | +| `HEMMELIG_REQUIRE_REGISTERED_USER` | Only registered users create secrets | `false` | +| `HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password signup (social only) | `false` | +| `HEMMELIG_WEBHOOK_ENABLED` | Enable webhook notifications | `false` | +| `HEMMELIG_WEBHOOK_URL` | Webhook endpoint URL | `""` | +| `HEMMELIG_WEBHOOK_SECRET` | HMAC secret for webhook signatures | `""` | +| `HEMMELIG_WEBHOOK_ON_VIEW` | Send webhook when secret is viewed | `true` | +| `HEMMELIG_WEBHOOK_ON_BURN` | Send webhook when secret is burned | `true` | +| `HEMMELIG_METRICS_ENABLED` | Enable Prometheus metrics endpoint | `false` | +| `HEMMELIG_METRICS_SECRET` | Bearer token for `/api/metrics` | `""` | + +See `docs/managed.md` for full documentation. + +--- + +## Feature Reference + +### Organization Features + +Hemmelig supports enterprise deployments with: + +- **Invite-Only Registration** - Require invite codes to sign up +- **Email Domain Restrictions** - Limit signups to specific domains (e.g., `company.com`) +- **Instance Settings** - Configure `allowRegistration`, `requireInvite`, `allowedEmailDomains` + +### Analytics System + +Privacy-focused analytics with: + +- HMAC-SHA256 hashing for anonymous visitor IDs (IPs never stored) +- Automatic bot filtering using `isbot` +- Tracks: Homepage (`/`) and Secret view page (`/secret`) +- Admin dashboard at `/dashboard/analytics` + +### Authentication + +better-auth provides: + +- Session-based authentication +- Two-factor authentication (2FA) with TOTP +- Custom signup hooks for email domain validation +- Admin user management +- Social login providers (GitHub, Google, Microsoft, Discord, GitLab, Apple, Twitter) + +### Social Login Providers + +Social login can be configured via: + +1. **Admin Dashboard** - Instance Settings > Social Login tab (when not in managed mode) +2. **Environment Variables** - Using `HEMMELIG_AUTH_*` variables (always available) + +Environment variable format: + +```bash +HEMMELIG_AUTH_GITHUB_ID=your-client-id +HEMMELIG_AUTH_GITHUB_SECRET=your-client-secret +HEMMELIG_AUTH_GOOGLE_ID=your-client-id +HEMMELIG_AUTH_GOOGLE_SECRET=your-client-secret +# ... etc for microsoft, discord, gitlab, apple, twitter +``` + +See `docs/social-login.md` for full setup instructions. + +--- + +## Need Help? + +- Check existing patterns in the codebase first +- Read related components/routes for context +- When in doubt, ask for clarification rather than making assumptions + +--- + +_This document is the source of truth for development practices in this repository._ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27dc277 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 + +# Prisma client generation stage - runs on native architecture to avoid QEMU issues +FROM --platform=$BUILDPLATFORM node:25-slim AS prisma-gen +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts +COPY prisma ./prisma +COPY prisma.config.ts ./ +ENV DATABASE_URL="file:/app/database/hemmelig.db" +RUN npx prisma generate --schema=prisma/schema.prisma --generator client + +# Build stage +FROM node:25-slim AS builder +RUN apt-get update && apt-get install -y python3 make g++ openssl ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY package.json package-lock.json ./ +ENV NODE_ENV=development +RUN npm ci +COPY prisma ./prisma +COPY prisma.config.ts ./ +# Copy pre-generated Prisma client from native build +COPY --from=prisma-gen /app/prisma/generated ./prisma/generated +COPY api ./api +COPY src ./src +COPY public ./public +COPY index.html tsconfig*.json vite.config.ts tailwind.config.js ./ +COPY server.ts ./ +RUN npm run build + +# Production dependencies +FROM node:25-slim AS deps +RUN apt-get update && apt-get install -y python3 make g++ openssl ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY package.json package-lock.json ./ +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma.config.ts ./ +# Copy pre-generated Prisma client from native build +COPY --from=prisma-gen /app/prisma/generated ./prisma/generated +ENV NODE_ENV=production +RUN npm ci --omit=dev --ignore-scripts && \ + npm rebuild better-sqlite3 && \ + npm cache clean --force && \ + rm -rf /root/.npm /tmp/* + +# Final image +FROM node:25-slim +RUN apt-get update && apt-get install -y wget openssl ca-certificates gosu && rm -rf /var/lib/apt/lists/* && \ + groupadd -r app && useradd -r -g app -m -d /home/app app +WORKDIR /app +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/server.ts ./ +COPY --from=builder /app/api ./api +COPY --from=builder /app/prisma/schema.prisma ./prisma/schema.prisma +COPY --from=builder /app/prisma/migrations ./prisma/migrations +COPY --from=builder /app/prisma.config.ts ./ +COPY --from=deps /app/package.json ./ +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/prisma/generated ./prisma/generated +RUN mkdir -p /app/database /app/uploads && chown -R app:app /app +COPY --chown=app:app scripts/docker-entrypoint.sh /app/docker-entrypoint.sh +RUN chmod +x /app/docker-entrypoint.sh + +EXPOSE 3000 +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATABASE_URL=file:/app/database/hemmelig.db + +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health/ready || exit 1 + +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aad3217 --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +O'Saasy License Agreement +Copyright © 2025, Bjarne Øverli. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +No licensee or downstream recipient may use the Software (including any modified or derivative versions) to directly compete with the original Licensor by offering it to third parties as a hosted, managed, or Software-as-a-Service (SaaS) product or cloud service where the primary value of the service is the functionality of the Software itself. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a83fac --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +
+ hemmelig +
+ +

Hemmelig - Encrypted Secret Sharing

+ +

+ Share sensitive information securely with client-side encryption and self-destructing messages. +

+ +

+ Try it online • + Deploy to terces.cloud • + Quick Start • + Docker Guide • + Configuration +

+ +

+ Docker pulls + Deploy to terces.cloud + Buy Me a Coffee +

+ +## How It Works + +1. Enter your secret on [hemmelig.app](https://hemmelig.app) or your self-hosted instance +2. Set expiration time, view limits, and optional password +3. Share the generated link with your recipient +4. The secret is automatically deleted after being viewed or expired + +**Zero-knowledge architecture:** All encryption happens in your browser. The server only stores encrypted data and never sees your secrets or encryption keys. + +## Features + +- **Client-side AES-256-GCM encryption** - Your data is encrypted before leaving your browser +- **Self-destructing secrets** - Configurable expiration and view limits +- **Password protection** - Optional additional security layer +- **IP restrictions** - Limit access to specific IP ranges +- **File uploads** - Share encrypted files (authenticated users) +- **Rich text editor** - Format your secrets with styling +- **QR codes** - Easy mobile sharing +- **Multi-language support** - Available in multiple languages +- **Webhook notifications** - Get notified when secrets are viewed or burned ([docs](docs/webhook.md)) + +## Quick Start + +### Docker (Recommended) + +```bash +docker run -d \ + --name hemmelig \ + -p 3000:3000 \ + -v hemmelig-data:/app/database \ + -v hemmelig-uploads:/app/uploads \ + -e DATABASE_URL="file:/app/database/hemmelig.db" \ + -e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \ + -e BETTER_AUTH_URL="https://your-domain.com" \ + hemmelig/hemmelig:v7 +``` + +### Docker Compose + +```bash +git clone https://github.com/HemmeligOrg/Hemmelig.app.git +cd Hemmelig.app + +# Edit docker-compose.yml with your settings +docker compose up -d +``` + +See [Docker Guide](docs/docker.md) for detailed deployment instructions. + +### CLI + +Create secrets directly from the command line: + +```bash +# Download the binary (recommended for CI/CD) +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v1.0.1/hemmelig-linux-amd64 -o hemmelig +chmod +x hemmelig + +# Or install via npm +npm install -g hemmelig + +# Create a secret +hemmelig "my secret message" + +# With options +hemmelig "API key: sk-1234" -t "Production API Key" -e 7d -v 3 +``` + +See [CLI Documentation](docs/cli.md) for all platforms and CI/CD integration examples. + +## Documentation + +- [Docker Deployment](docs/docker.md) - Complete Docker setup guide +- [Helm Chart](docs/helm.md) - Kubernetes deployment with Helm +- [Environment Variables](docs/env.md) - All configuration options +- [Managed Mode](docs/managed.md) - Configure instance settings via environment variables +- [CLI](docs/cli.md) - Command-line interface for automation and CI/CD +- [Encryption](docs/encryption.md) - How client-side encryption works +- [Social Login](docs/social-login.md) - OAuth provider setup (GitHub, Google, etc.) +- [Secret Requests](docs/secret-request.md) - Request secrets from others securely +- [Webhooks](docs/webhook.md) - Webhook notifications for secret events +- [Health Checks](docs/health.md) - Liveness and readiness probes for container orchestration +- [Prometheus Metrics](docs/metrics.md) - Monitor your instance with Prometheus +- [API Documentation](docs/api.md) - REST API reference and OpenAPI spec +- [SDK Generation](docs/sdk.md) - Generate client SDKs from OpenAPI spec +- [E2E Testing](docs/e2e.md) - End-to-end testing with Playwright +- [Upgrading from v6](docs/upgrade.md) - Migration guide for v6 to v7 + +## Development + +```bash +npm install +npm run dev +npm run dev:api +``` + +## Deploy to terces.cloud + +Want a hassle-free managed Hemmelig instance? [terces.cloud](https://terces.cloud) offers fully managed Hemmelig hosting for **$20/month**. Get your own private instance with automatic updates, backups, and zero maintenance overhead. + +Deploy to terces.cloud + +## Hetzner Cloud Referral + +Hemmelig is proudly hosted on [Hetzner Cloud](https://hetzner.cloud/?ref=Id028KbCZQoD). Hetzner provides reliable and scalable cloud solutions, making it an ideal choice for hosting secure applications like Hemmelig. By using our [referral link](https://hetzner.cloud/?ref=Id028KbCZQoD), you can join Hetzner Cloud and receive €20/$20 in credits. Once you spend at least €10/$10 (excluding credits), Hemmelig will receive €10/$10 in Hetzner Cloud credits. This is a great opportunity to explore Hetzner's services while supporting Hemmelig. + +## License + +O'Saasy License Agreement - Copyright © 2025, Bjarne Øverli. + +This project is licensed under a modified MIT license that prohibits using the software to compete with the original licensor as a hosted SaaS product. See [LICENSE](LICENSE) for details. diff --git a/api/app.ts b/api/app.ts new file mode 100644 index 0000000..a349137 --- /dev/null +++ b/api/app.ts @@ -0,0 +1,158 @@ +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import { csrf } from 'hono/csrf'; +import { etag, RETAINED_304_HEADERS } from 'hono/etag'; +import { HTTPException } from 'hono/http-exception'; +import { logger } from 'hono/logger'; +import { requestId } from 'hono/request-id'; +import { secureHeaders } from 'hono/secure-headers'; +import { timeout } from 'hono/timeout'; +import { trimTrailingSlash } from 'hono/trailing-slash'; +import { ZodError } from 'zod'; + +import { auth } from './auth'; +import config from './config'; +import startJobs from './jobs'; +import prisma from './lib/db'; +import ratelimit from './middlewares/ratelimit'; +import routes from './routes'; + +// Initialize Hono app +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + session: typeof auth.$Infer.Session.session | null; + }; +}>(); + +// Global error handler +app.onError((err, c) => { + const requestId = c.get('requestId') || 'unknown'; + + // Handle Zod validation errors + if (err instanceof ZodError) { + console.error(`[${requestId}] Validation error:`, err.flatten()); + return c.json( + { + error: 'Validation failed', + details: err.flatten().fieldErrors, + }, + 400 + ); + } + + // Handle HTTP exceptions (thrown by Hono or middleware) + if (err instanceof HTTPException) { + console.error(`[${requestId}] HTTP exception:`, { + status: err.status, + message: err.message, + }); + return c.json({ error: err.message }, err.status); + } + + // Handle all other errors + console.error(`[${requestId}] Unhandled error:`, { + error: err.message, + stack: err.stack, + }); + + // Don't expose internal error details in production + return c.json({ error: 'Internal server error' }, 500); +}); + +// Handle 404 - route not found +app.notFound((c) => { + return c.json({ error: 'Not found' }, 404); +}); + +// Start the background jobs +startJobs(); + +// Add the middlewares +// More middlewares can be found here: +// https://hono.dev/docs/middleware/builtin/basic-auth +app.use(secureHeaders()); +app.use(logger()); +app.use(trimTrailingSlash()); +app.use(`/*`, requestId()); +app.use(`/*`, timeout(15 * 1000)); // 15 seconds timeout to the API calls +app.use(ratelimit); + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag +app.use( + `/*`, + etag({ + retainedHeaders: ['x-message', ...RETAINED_304_HEADERS], + }) +); + +// Configure CORS with trusted origins +const trustedOrigins = config.get('trustedOrigins', []); +app.use( + `/*`, + cors({ + origin: trustedOrigins, + credentials: true, + }) +); + +// Configure CSRF protection (exclude auth routes for OAuth callbacks) +app.use('/*', async (c, next) => { + // Skip CSRF for auth routes (OAuth callbacks come from external origins) + if (c.req.path.startsWith('/auth/')) { + return next(); + } + return csrf({ + origin: trustedOrigins, + })(c, next); +}); + +// Custom middlewares +app.use('*', async (c, next) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + + if (!session) { + c.set('user', null); + c.set('session', null); + return next(); + } + + c.set('user', session.user); + c.set('session', session.session); + return next(); +}); + +// Add the routes +app.on(['POST', 'GET'], `/auth/*`, (c) => { + return auth.handler(c.req.raw); +}); + +// Add the application routes +app.route('/', routes); + +// https://hono.dev/docs/guides/rpc#rpc +export type AppType = typeof routes; + +export default app; + +// Handle graceful shutdown +process.on('SIGINT', async () => { + await prisma.$disconnect(); + console.info('Disconnected from database'); + process.exit(0); +}); + +// Handle uncaught exceptions +process.on('uncaughtException', (error: Error) => { + console.error('Uncaught Exception', { + error: error.message, + stack: error.stack, + }); + process.exit(1); +}); + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason: unknown) => { + console.error('Unhandled Rejection', { reason }); + process.exit(1); +}); diff --git a/api/auth.ts b/api/auth.ts new file mode 100644 index 0000000..2c915a8 --- /dev/null +++ b/api/auth.ts @@ -0,0 +1,176 @@ +import { betterAuth } from 'better-auth'; +import { prismaAdapter } from 'better-auth/adapters/prisma'; +import { APIError } from 'better-auth/api'; +import { admin, twoFactor, username } from 'better-auth/plugins'; +import { genericOAuth } from 'better-auth/plugins/generic-oauth'; +import { randomBytes } from 'crypto'; +import config, { type SocialProviderConfig } from './config'; +import prisma from './lib/db'; +import { validatePassword } from './validations/password'; + +// Generate a unique username from email +const generateUsernameFromEmail = (email: string): string => { + const localPart = email.split('@')[0] || 'user'; + // Sanitize: only keep alphanumeric characters and underscores + const sanitized = localPart.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase(); + // Add random suffix to ensure uniqueness (cryptographically secure) + const randomSuffix = randomBytes(4).toString('hex').substring(0, 6); + return `${sanitized}_${randomSuffix}`; +}; + +// Build better-auth social providers configuration dynamically +const buildBetterAuthSocialProviders = () => { + const providers = config.getSocialProviders(); + const betterAuthProviders: Record< + string, + { + clientId: string; + clientSecret: string; + tenantId?: string; + issuer?: string; + mapProfileToUser?: (profile: { email?: string; name?: string }) => { username: string }; + } + > = {}; + + for (const [provider, providerConfig] of Object.entries(providers)) { + const typedConfig = providerConfig as SocialProviderConfig; + betterAuthProviders[provider] = { + clientId: typedConfig.clientId, + clientSecret: typedConfig.clientSecret, + ...(typedConfig.tenantId && { tenantId: typedConfig.tenantId }), + ...(typedConfig.issuer && { issuer: typedConfig.issuer }), + mapProfileToUser: (profile) => ({ + username: generateUsernameFromEmail(profile.email || profile.name || 'user'), + }), + }; + } + + return betterAuthProviders; +}; + +// Build better-auth plugins array +const buildPlugins = () => { + const plugins: any[] = [username(), admin(), twoFactor()]; + + const genericProviders = config.getGenericOAuthProviders(); + if (genericProviders.length > 0) { + plugins.push( + genericOAuth({ + config: genericProviders.map((provider) => ({ + ...provider, + // Map profile to include username + mapProfileToUser: (profile: any) => ({ + username: generateUsernameFromEmail( + profile.email || profile.name || 'user' + ), + }), + })), + }) + ); + } + + return plugins; +}; + +export const auth = betterAuth({ + appName: 'Hemmelig', + baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000', + database: prismaAdapter(prisma, { + provider: 'sqlite', + }), + emailAndPassword: { + enabled: true, + // Set to 1 so better-auth doesn't reject weak current passwords during password change. + // Password strength for new passwords is enforced by our Zod schema (updatePasswordSchema) + // and for sign-up by the before hook below. + minPasswordLength: 1, + }, + socialProviders: buildBetterAuthSocialProviders(), + account: { + accountLinking: { + enabled: true, + trustedProviders: [ + 'gitlab', + 'github', + 'google', + 'microsoft', + 'discord', + 'apple', + 'twitter', + // Add all generic OAuth provider IDs as trusted + ...config.getGenericOAuthProviders().map((p) => p.providerId), + ], + }, + }, + plugins: buildPlugins(), + trustedOrigins: config.get('trustedOrigins'), + hooks: { + before: async (context) => { + // Only apply validation to email/password sign-up + if (context.path !== '/sign-up/email') { + return; + } + + const body = context.body as { email?: string; password?: string }; + const email = body?.email; + const password = body?.password; + + if (!email) { + return; + } + + // Validate password strength for sign-up + if (password) { + const passwordError = validatePassword(password); + if (passwordError) { + throw new APIError('BAD_REQUEST', { message: passwordError }); + } + } + + // Get instance settings + const settings = await prisma.instanceSettings.findFirst({ + select: { allowedEmailDomains: true, disableEmailPasswordSignup: true }, + }); + + // Check if email/password signup is disabled + if (settings?.disableEmailPasswordSignup) { + throw new APIError('FORBIDDEN', { + message: 'Email/password registration is disabled. Please use social login.', + }); + } + + const allowedDomains = settings?.allowedEmailDomains?.trim(); + + // If no domains configured, allow all + if (!allowedDomains) { + return; + } + + // Parse comma-separated domains + const domains = allowedDomains + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter((d) => d.length > 0); + + if (domains.length === 0) { + return; + } + + // Extract domain from email + const emailDomain = email.split('@')[1]?.toLowerCase(); + + if (!emailDomain || !domains.includes(emailDomain)) { + throw new APIError('FORBIDDEN', { + message: 'Email domain not allowed', + }); + } + }, + }, +}); + +// Export enabled social providers for frontend consumption +export const getEnabledSocialProviders = (): string[] => { + const standardProviders = Object.keys(config.getSocialProviders()); + const genericProviders = config.getGenericOAuthProviders().map((p) => p.providerId); + return [...standardProviders, ...genericProviders]; +}; diff --git a/api/config.ts b/api/config.ts new file mode 100644 index 0000000..17bf481 --- /dev/null +++ b/api/config.ts @@ -0,0 +1,255 @@ +import dlv from 'dlv'; + +const isProduction = process.env.NODE_ENV === 'production'; + +// Helper to parse boolean from env, returns undefined if not set +const parseBoolean = (value: string | undefined): boolean | undefined => { + if (value === undefined || value === null || value === '') return undefined; + return value.toLowerCase() === 'true'; +}; + +// Helper to parse integer from env, returns undefined if not set +const parseInteger = (value: string | undefined): number | undefined => { + if (value === undefined || value === null || value === '') return undefined; + const parsed = parseInt(value, 10); + return isNaN(parsed) ? undefined : parsed; +}; + +// Social provider configuration type +export interface SocialProviderConfig { + clientId: string; + clientSecret: string; + tenantId?: string; // For Microsoft/Azure AD + issuer?: string; // For self-hosted instances (e.g., GitLab) +} + +// Generic OAuth provider configuration type (for better-auth genericOAuth plugin) +export interface GenericOAuthProviderConfig { + providerId: string; + discoveryUrl?: string; + authorizationUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + clientId: string; + clientSecret: string; + scopes?: string[]; + pkce?: boolean; +} + +// Build social providers config dynamically from env vars +const buildSocialProviders = () => { + const providers: Record = {}; + + // GitHub + if (process.env.HEMMELIG_AUTH_GITHUB_ID && process.env.HEMMELIG_AUTH_GITHUB_SECRET) { + providers.github = { + clientId: process.env.HEMMELIG_AUTH_GITHUB_ID, + clientSecret: process.env.HEMMELIG_AUTH_GITHUB_SECRET, + }; + } + + // Google + if (process.env.HEMMELIG_AUTH_GOOGLE_ID && process.env.HEMMELIG_AUTH_GOOGLE_SECRET) { + providers.google = { + clientId: process.env.HEMMELIG_AUTH_GOOGLE_ID, + clientSecret: process.env.HEMMELIG_AUTH_GOOGLE_SECRET, + }; + } + + // Microsoft (Azure AD) + if (process.env.HEMMELIG_AUTH_MICROSOFT_ID && process.env.HEMMELIG_AUTH_MICROSOFT_SECRET) { + providers.microsoft = { + clientId: process.env.HEMMELIG_AUTH_MICROSOFT_ID, + clientSecret: process.env.HEMMELIG_AUTH_MICROSOFT_SECRET, + tenantId: process.env.HEMMELIG_AUTH_MICROSOFT_TENANT_ID, + }; + } + + // Discord + if (process.env.HEMMELIG_AUTH_DISCORD_ID && process.env.HEMMELIG_AUTH_DISCORD_SECRET) { + providers.discord = { + clientId: process.env.HEMMELIG_AUTH_DISCORD_ID, + clientSecret: process.env.HEMMELIG_AUTH_DISCORD_SECRET, + }; + } + + // GitLab + if (process.env.HEMMELIG_AUTH_GITLAB_ID && process.env.HEMMELIG_AUTH_GITLAB_SECRET) { + providers.gitlab = { + clientId: process.env.HEMMELIG_AUTH_GITLAB_ID, + clientSecret: process.env.HEMMELIG_AUTH_GITLAB_SECRET, + issuer: process.env.HEMMELIG_AUTH_GITLAB_ISSUER, + }; + } + + // Apple + if (process.env.HEMMELIG_AUTH_APPLE_ID && process.env.HEMMELIG_AUTH_APPLE_SECRET) { + providers.apple = { + clientId: process.env.HEMMELIG_AUTH_APPLE_ID, + clientSecret: process.env.HEMMELIG_AUTH_APPLE_SECRET, + }; + } + + // Twitter/X + if (process.env.HEMMELIG_AUTH_TWITTER_ID && process.env.HEMMELIG_AUTH_TWITTER_SECRET) { + providers.twitter = { + clientId: process.env.HEMMELIG_AUTH_TWITTER_ID, + clientSecret: process.env.HEMMELIG_AUTH_TWITTER_SECRET, + }; + } + + return providers; +}; + +// Build generic OAuth providers from JSON env var +const buildGenericOAuthProviders = (): GenericOAuthProviderConfig[] => { + const genericOAuthEnv = process.env.HEMMELIG_AUTH_GENERIC_OAUTH; + + if (!genericOAuthEnv) { + return []; + } + + try { + const parsed = JSON.parse(genericOAuthEnv); + if (!Array.isArray(parsed)) { + console.error('HEMMELIG_AUTH_GENERIC_OAUTH must be a JSON array'); + return []; + } + + // Validate each provider config + return parsed.filter((provider: any) => { + if (!provider.providerId || !provider.clientId || !provider.clientSecret) { + console.error( + `Invalid generic OAuth provider config: missing required fields (providerId, clientId, or clientSecret)` + ); + return false; + } + + // Must have either discoveryUrl OR all three URLs (authorization, token, userInfo) + const hasDiscoveryUrl = !!provider.discoveryUrl; + const hasManualUrls = !!( + provider.authorizationUrl && + provider.tokenUrl && + provider.userInfoUrl + ); + + if (!hasDiscoveryUrl && !hasManualUrls) { + console.error( + `Invalid generic OAuth provider config for "${provider.providerId}": must provide either discoveryUrl OR all of (authorizationUrl, tokenUrl, userInfoUrl)` + ); + return false; + } + + return true; + }) as GenericOAuthProviderConfig[]; + } catch (error) { + console.error('Failed to parse HEMMELIG_AUTH_GENERIC_OAUTH:', error); + return []; + } +}; + +const socialProviders = buildSocialProviders(); +const genericOAuthProviders = buildGenericOAuthProviders(); + +// Managed mode: all settings are controlled via environment variables +const isManaged = parseBoolean(process.env.HEMMELIG_MANAGED) ?? false; + +// Managed mode settings (only used when HEMMELIG_MANAGED=true) +const managedSettings = isManaged + ? { + // General settings + instanceName: process.env.HEMMELIG_INSTANCE_NAME ?? '', + instanceDescription: process.env.HEMMELIG_INSTANCE_DESCRIPTION ?? '', + instanceLogo: process.env.HEMMELIG_INSTANCE_LOGO ?? '', + allowRegistration: parseBoolean(process.env.HEMMELIG_ALLOW_REGISTRATION) ?? true, + requireEmailVerification: + parseBoolean(process.env.HEMMELIG_REQUIRE_EMAIL_VERIFICATION) ?? false, + defaultSecretExpiration: + parseInteger(process.env.HEMMELIG_DEFAULT_SECRET_EXPIRATION) ?? 72, + maxSecretSize: parseInteger(process.env.HEMMELIG_MAX_SECRET_SIZE) ?? 1024, + importantMessage: process.env.HEMMELIG_IMPORTANT_MESSAGE ?? '', + + // Security settings + allowPasswordProtection: + parseBoolean(process.env.HEMMELIG_ALLOW_PASSWORD_PROTECTION) ?? true, + allowIpRestriction: parseBoolean(process.env.HEMMELIG_ALLOW_IP_RESTRICTION) ?? true, + enableRateLimiting: parseBoolean(process.env.HEMMELIG_ENABLE_RATE_LIMITING) ?? true, + rateLimitRequests: parseInteger(process.env.HEMMELIG_RATE_LIMIT_REQUESTS) ?? 100, + rateLimitWindow: parseInteger(process.env.HEMMELIG_RATE_LIMIT_WINDOW) ?? 60, + + // Organization settings + requireInviteCode: parseBoolean(process.env.HEMMELIG_REQUIRE_INVITE_CODE) ?? false, + allowedEmailDomains: process.env.HEMMELIG_ALLOWED_EMAIL_DOMAINS ?? '', + requireRegisteredUser: + parseBoolean(process.env.HEMMELIG_REQUIRE_REGISTERED_USER) ?? false, + disableEmailPasswordSignup: + parseBoolean(process.env.HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP) ?? false, + + // Webhook settings + webhookEnabled: parseBoolean(process.env.HEMMELIG_WEBHOOK_ENABLED) ?? false, + webhookUrl: process.env.HEMMELIG_WEBHOOK_URL ?? '', + webhookSecret: process.env.HEMMELIG_WEBHOOK_SECRET ?? '', + webhookOnView: parseBoolean(process.env.HEMMELIG_WEBHOOK_ON_VIEW) ?? true, + webhookOnBurn: parseBoolean(process.env.HEMMELIG_WEBHOOK_ON_BURN) ?? true, + + // Metrics settings + metricsEnabled: parseBoolean(process.env.HEMMELIG_METRICS_ENABLED) ?? false, + metricsSecret: process.env.HEMMELIG_METRICS_SECRET ?? '', + + // File upload settings + allowFileUploads: parseBoolean(process.env.HEMMELIG_ALLOW_FILE_UPLOADS) ?? true, + } + : null; + +const config = { + server: { + port: Number(process.env.HEMMELIG_PORT) || 3000, + }, + trustedOrigins: [ + ...(!isProduction ? ['http://localhost:5173'] : []), + process.env.HEMMELIG_TRUSTED_ORIGIN || '', + ].filter(Boolean), + general: { + instanceName: process.env.HEMMELIG_INSTANCE_NAME, + instanceDescription: process.env.HEMMELIG_INSTANCE_DESCRIPTION, + instanceLogo: process.env.HEMMELIG_INSTANCE_LOGO, + allowRegistration: parseBoolean(process.env.HEMMELIG_ALLOW_REGISTRATION), + }, + security: { + allowPasswordProtection: parseBoolean(process.env.HEMMELIG_ALLOW_PASSWORD_PROTECTION), + allowIpRestriction: parseBoolean(process.env.HEMMELIG_ALLOW_IP_RESTRICTION), + }, + analytics: { + enabled: parseBoolean(process.env.HEMMELIG_ANALYTICS_ENABLED) ?? true, + hmacSecret: + process.env.HEMMELIG_ANALYTICS_HMAC_SECRET || 'default-analytics-secret-change-me', + }, + socialProviders, +}; + +if (!process.env.HEMMELIG_ANALYTICS_HMAC_SECRET && config.analytics.enabled) { + console.warn( + 'WARNING: HEMMELIG_ANALYTICS_HMAC_SECRET is not set. Analytics visitor IDs are generated ' + + 'with a default secret, making them predictable. Set a random secret for production use.' + ); +} + +/** + * A type-safe utility to get a value from the configuration. + * Its return type is inferred from the type of the default value. + * @param path The dot-notation path to the config value (e.g., 'server.port'). + * @param defaultValue A default value to return if the path is not found. + * @returns The found configuration value or the default value. + */ +function get(path: string, defaultValue?: T): T { + return dlv(config, path, defaultValue) as T; +} + +// Export the get function and social providers helper +export default { + get, + getSocialProviders: () => config.socialProviders, + getGenericOAuthProviders: () => genericOAuthProviders, + isManaged: () => isManaged, + getManagedSettings: () => managedSettings, +}; diff --git a/api/jobs/expired.ts b/api/jobs/expired.ts new file mode 100644 index 0000000..ac9d1f8 --- /dev/null +++ b/api/jobs/expired.ts @@ -0,0 +1,67 @@ +import { unlink } from 'fs/promises'; +import prisma from '../lib/db'; + +export const deleteExpiredSecrets = async () => { + try { + const now = new Date(); + await prisma.secrets.deleteMany({ + where: { + OR: [ + { + expiresAt: { + lte: now, + }, + }, + { + views: 0, + }, + ], + }, + }); + } catch (error) { + console.error('Error deleting expired secrets:', error); + } +}; + +export const deleteOrphanedFiles = async () => { + try { + // Find files that are not associated with any secret + const orphanedFiles = await prisma.file.findMany({ + where: { + secrets: { + none: {}, + }, + }, + }); + + if (orphanedFiles.length === 0) { + return; + } + + // Delete files from disk in parallel for better performance + const deleteResults = await Promise.allSettled( + orphanedFiles.map((file) => unlink(file.path)) + ); + + // Log any failures (file may already be deleted or inaccessible) + deleteResults.forEach((result, index) => { + if (result.status === 'rejected') { + console.error( + `Failed to delete file from disk: ${orphanedFiles[index].path}`, + result.reason + ); + } + }); + + // Delete orphaned file records from database + await prisma.file.deleteMany({ + where: { + id: { + in: orphanedFiles.map((f) => f.id), + }, + }, + }); + } catch (error) { + console.error('Error deleting orphaned files:', error); + } +}; diff --git a/api/jobs/index.ts b/api/jobs/index.ts new file mode 100644 index 0000000..a268605 --- /dev/null +++ b/api/jobs/index.ts @@ -0,0 +1,14 @@ +import { Cron } from 'croner'; +import { deleteExpiredSecrets, deleteOrphanedFiles } from './expired'; + +// https://crontab.guru +export default function startJobs() { + // This function can be used to start any other jobs in the future + console.log('Job scheduler initialized.'); + + // Running every minute + new Cron('* * * * *', async () => { + await deleteExpiredSecrets(); + await deleteOrphanedFiles(); + }); +} diff --git a/api/lib/analytics.ts b/api/lib/analytics.ts new file mode 100644 index 0000000..fa21296 --- /dev/null +++ b/api/lib/analytics.ts @@ -0,0 +1,62 @@ +import { createHmac } from 'crypto'; +import config from '../config'; + +const analyticsConfig = config.get('analytics') as { enabled: boolean; hmacSecret: string }; + +/** + * Creates a unique, anonymous visitor ID using HMAC-SHA256. + * This ensures privacy by never storing the raw IP address. + */ +export function createVisitorId(ip: string, userAgent: string): string { + return createHmac('sha256', analyticsConfig.hmacSecret) + .update(ip + userAgent) + .digest('hex'); +} + +/** + * Validates that a path is safe for analytics tracking. + * Prevents injection of malicious paths. + */ +export function isValidAnalyticsPath(path: string): boolean { + const pathRegex = /^\/[a-zA-Z0-9\-?=&/#]*$/; + return pathRegex.test(path) && path.length <= 255; +} + +/** + * Checks if analytics tracking is enabled. + */ +export function isAnalyticsEnabled(): boolean { + return analyticsConfig.enabled; +} + +/** + * Calculates the start date for a given time range. + * @param timeRange - Time range string (7d, 14d, 30d) + * @returns Start date for the query + */ +export function getStartDateForTimeRange(timeRange: '7d' | '14d' | '30d'): Date { + const now = new Date(); + const startDate = new Date(); + + switch (timeRange) { + case '7d': + startDate.setDate(now.getDate() - 7); + break; + case '14d': + startDate.setDate(now.getDate() - 14); + break; + case '30d': + startDate.setDate(now.getDate() - 30); + break; + } + + return startDate; +} + +/** + * Calculates percentage with fixed decimal places, returning 0 if total is 0. + */ +export function calculatePercentage(value: number, total: number, decimals = 2): number { + if (total === 0) return 0; + return parseFloat(((value / total) * 100).toFixed(decimals)); +} diff --git a/api/lib/constants.ts b/api/lib/constants.ts new file mode 100644 index 0000000..0a25297 --- /dev/null +++ b/api/lib/constants.ts @@ -0,0 +1,85 @@ +/** + * Time constants in milliseconds + */ +export const TIME = { + /** One second in milliseconds */ + SECOND_MS: 1000, + /** One minute in milliseconds */ + MINUTE_MS: 60 * 1000, + /** One hour in milliseconds */ + HOUR_MS: 60 * 60 * 1000, + /** One day in milliseconds */ + DAY_MS: 24 * 60 * 60 * 1000, +} as const; + +/** + * Secret-related constants + */ +export const SECRET = { + /** Grace period for file downloads after last view (5 minutes) */ + FILE_DOWNLOAD_GRACE_PERIOD_MS: 5 * TIME.MINUTE_MS, +} as const; + +/** + * File upload constants + */ +export const FILE = { + /** Default max file size in KB (10MB) */ + DEFAULT_MAX_SIZE_KB: 10240, +} as const; + +/** + * Valid secret expiration times in seconds + */ +export const EXPIRATION_TIMES_SECONDS = [ + 2419200, // 28 days + 1209600, // 14 days + 604800, // 7 days + 259200, // 3 days + 86400, // 1 day + 43200, // 12 hours + 14400, // 4 hours + 3600, // 1 hour + 1800, // 30 minutes + 300, // 5 minutes +] as const; + +/** + * Instance settings fields - public (safe for all users) + */ +export const PUBLIC_SETTINGS_FIELDS = { + instanceName: true, + instanceDescription: true, + instanceLogo: true, + allowRegistration: true, + defaultSecretExpiration: true, + maxSecretSize: true, + allowPasswordProtection: true, + allowIpRestriction: true, + allowFileUploads: true, + requireRegisteredUser: true, + importantMessage: true, + disableEmailPasswordSignup: true, +} as const; + +/** + * Instance settings fields - admin only (all fields) + */ +export const ADMIN_SETTINGS_FIELDS = { + ...PUBLIC_SETTINGS_FIELDS, + requireEmailVerification: true, + enableRateLimiting: true, + rateLimitRequests: true, + rateLimitWindow: true, + requireInviteCode: true, + allowedEmailDomains: true, + disableEmailPasswordSignup: true, + webhookEnabled: true, + webhookUrl: true, + webhookSecret: true, + webhookOnView: true, + webhookOnBurn: true, + importantMessage: true, + metricsEnabled: true, + metricsSecret: true, +} as const; diff --git a/api/lib/db.ts b/api/lib/db.ts new file mode 100644 index 0000000..3ed7a50 --- /dev/null +++ b/api/lib/db.ts @@ -0,0 +1,19 @@ +import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { PrismaClient } from '../../prisma/generated/prisma/client.js'; + +const prismaClientSingleton = () => { + const adapter = new PrismaBetterSqlite3({ + url: process.env.DATABASE_URL || 'file:./database/hemmelig.db', + }); + return new PrismaClient({ adapter }); +}; + +declare global { + var prisma: undefined | ReturnType; +} + +const db = globalThis.prisma ?? prismaClientSingleton(); + +export default db; + +if (process.env.NODE_ENV !== 'production') globalThis.prisma = db; diff --git a/api/lib/files.ts b/api/lib/files.ts new file mode 100644 index 0000000..8214369 --- /dev/null +++ b/api/lib/files.ts @@ -0,0 +1,77 @@ +import { mkdir } from 'fs/promises'; +import { basename, join, resolve } from 'path'; +import { FILE } from './constants'; +import settingsCache from './settings'; + +/** Upload directory path */ +export const UPLOAD_DIR = resolve(process.cwd(), 'uploads'); + +/** + * Sanitizes a filename by removing path traversal sequences and directory separators. + * Returns only the base filename to prevent directory escape attacks. + */ +export function sanitizeFilename(filename: string): string { + // Get only the base filename, stripping any directory components + const base = basename(filename); + // Remove any remaining null bytes or other dangerous characters + return base.replace(/[\x00-\x1f]/g, ''); +} + +/** + * Validates that a file path is safely within the upload directory. + * Prevents path traversal attacks by checking the resolved absolute path. + */ +export function isPathSafe(filePath: string): boolean { + const resolvedPath = resolve(filePath); + return resolvedPath.startsWith(UPLOAD_DIR + '/') || resolvedPath === UPLOAD_DIR; +} + +/** + * Gets max file size from instance settings (in KB), converted to bytes. + * Defaults to 10MB if not configured. + */ +export function getMaxFileSize(): number { + const settings = settingsCache.get('instanceSettings'); + const maxSecretSizeKB = settings?.maxSecretSize ?? FILE.DEFAULT_MAX_SIZE_KB; + return maxSecretSizeKB * 1024; // Convert KB to bytes +} + +/** + * Ensures the upload directory exists, creating it if necessary. + */ +export async function ensureUploadDir(): Promise { + try { + await mkdir(UPLOAD_DIR, { recursive: true }); + } catch (error) { + console.error('Failed to create upload directory:', error); + } +} + +/** + * Generates a safe file path within the upload directory. + * @param id - Unique identifier for the file + * @param originalFilename - Original filename to sanitize + * @returns Object with sanitized filename and full path, or null if invalid + */ +export function generateSafeFilePath( + id: string, + originalFilename: string +): { filename: string; path: string } | null { + const safeFilename = sanitizeFilename(originalFilename); + if (!safeFilename) { + return null; + } + + const filename = `${id}-${safeFilename}`; + const path = join(UPLOAD_DIR, filename); + + // Verify path is safe + if (!isPathSafe(path)) { + return null; + } + + return { filename, path }; +} + +// Initialize upload directory on module load +ensureUploadDir(); diff --git a/api/lib/password.ts b/api/lib/password.ts new file mode 100644 index 0000000..cc5b050 --- /dev/null +++ b/api/lib/password.ts @@ -0,0 +1,49 @@ +import * as argon2 from 'argon2'; + +/** + * Hashes a password using Argon2id. + * Uses Bun.password if available, otherwise falls back to argon2 npm package. + * @param password The plain-text password to hash. + * @returns A promise that resolves to the hashed password. + * @throws Will throw an error if hashing fails. + */ +export async function hash(password: string): Promise { + try { + // Try Bun's native password hashing first (uses Argon2) + if (typeof Bun !== 'undefined' && Bun.password) { + return await Bun.password.hash(password); + } + + // Fallback to argon2 npm package (Argon2id) + return await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 65536, // 64 MB + timeCost: 3, + parallelism: 4, + }); + } catch (error) { + console.error('Error during password hashing:', error); + throw new Error('Error hashing the password.'); + } +} + +/** + * Compares a plain-text password with a hash. + * @param password The plain-text password to compare. + * @param storedHash The hash to compare against. + * @returns A promise that resolves to true if the password matches the hash, otherwise false. + */ +export async function compare(password: string, storedHash: string): Promise { + try { + // Try Bun's native password verification first + if (typeof Bun !== 'undefined' && Bun.password) { + return await Bun.password.verify(password, storedHash); + } + + // Fallback to argon2 npm package + return await argon2.verify(storedHash, password); + } catch (error) { + console.error('Error during password comparison:', error); + return false; + } +} diff --git a/api/lib/settings.ts b/api/lib/settings.ts new file mode 100644 index 0000000..b011d4d --- /dev/null +++ b/api/lib/settings.ts @@ -0,0 +1,40 @@ +import prisma from './db'; + +const settingsCache = new Map(); + +/** + * Gets instance settings, fetching from database if not cached. + * Use this utility to avoid duplicating the cache-check pattern. + */ +export async function getInstanceSettings() { + let cachedSettings = settingsCache.get('instanceSettings'); + if (!cachedSettings) { + try { + cachedSettings = await prisma.instanceSettings.findFirst(); + if (cachedSettings) { + settingsCache.set('instanceSettings', cachedSettings); + } + } catch { + // Table may not exist yet (fresh database) + return null; + } + } + return cachedSettings; +} + +/** + * Updates the cached instance settings. + * Call this after modifying settings in the database. + */ +export function setCachedInstanceSettings(settings: unknown) { + settingsCache.set('instanceSettings', settings); +} + +/** + * Gets the raw settings cache (for direct access when needed). + */ +export function getSettingsCache() { + return settingsCache; +} + +export default settingsCache; diff --git a/api/lib/utils.ts b/api/lib/utils.ts new file mode 100644 index 0000000..3f7567e --- /dev/null +++ b/api/lib/utils.ts @@ -0,0 +1,130 @@ +import dns from 'dns/promises'; +import { type Context } from 'hono'; +import { isIP } from 'is-ip'; + +/** + * Handle not found error from Prisma + * @param error Error from Prisma operation + * @param c Hono context + * @returns JSON error response + */ +export const handleNotFound = (error: Error & { code?: string }, c: Context) => { + // Handle record not found error (Prisma P2025) + if (error?.code === 'P2025') { + return c.json({ error: 'Not found' }, 404); + } + + // Handle other errors + return c.json( + { + error: 'Failed to process the operation', + }, + 500 + ); +}; + +/** + * Get client IP from request headers + * @param c Hono context + * @returns Client IP address + */ +export const getClientIp = (c: Context): string => { + const forwardedFor = c.req.header('x-forwarded-for'); + if (forwardedFor) { + return forwardedFor.split(',')[0].trim(); + } + return ( + c.req.header('x-real-ip') || + c.req.header('cf-connecting-ip') || + c.req.header('client-ip') || + c.req.header('x-client-ip') || + c.req.header('x-cluster-client-ip') || + c.req.header('forwarded-for') || + c.req.header('forwarded') || + c.req.header('via') || + '127.0.0.1' + ); +}; + +// Patterns for private/internal IP addresses +const privateIpPatterns = [ + // Localhost variants + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^0\.0\.0\.0$/, + // Private IPv4 ranges + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^192\.168\.\d{1,3}\.\d{1,3}$/, + /^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, + // Link-local IPv4 + /^169\.254\.\d{1,3}\.\d{1,3}$/, + // IPv6 localhost + /^::1$/, + /^\[::1\]$/, + // IPv6 link-local + /^fe80:/i, + // IPv6 private (unique local addresses) + /^fc00:/i, + /^fd[0-9a-f]{2}:/i, +]; + +// Patterns for special domains that should always be blocked +const blockedHostnamePatterns = [ + /^localhost$/, + /\.local$/, + /\.internal$/, + /\.localhost$/, + /\.localdomain$/, +]; + +/** + * Check if an IP address is private/internal + * @param ip IP address to check + * @returns true if IP is private/internal + */ +const isPrivateIp = (ip: string): boolean => { + return privateIpPatterns.some((pattern) => pattern.test(ip)); +}; + +/** + * Check if a URL points to a private/internal address (SSRF protection) + * Resolves DNS to check actual IP addresses, preventing DNS rebinding attacks. + * @param url URL string to validate + * @returns Promise if URL is safe (not internal), Promise if it's a private/internal address + */ +export const isPublicUrl = async (url: string): Promise => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + + // Block special domain patterns (e.g., .local, .localhost) + if (blockedHostnamePatterns.some((pattern) => pattern.test(hostname))) { + return false; + } + + // If hostname is already an IP address, check it directly + if (isIP(hostname)) { + return !isPrivateIp(hostname); + } + + // Resolve DNS to get actual IP addresses + let addresses: string[] = []; + try { + const ipv4Addresses = await dns.resolve4(hostname).catch(() => []); + const ipv6Addresses = await dns.resolve6(hostname).catch(() => []); + addresses = [...ipv4Addresses, ...ipv6Addresses]; + } catch { + // DNS resolution failed - reject for safety + return false; + } + + // Require at least one resolvable address + if (addresses.length === 0) { + return false; + } + + // Check all resolved IPs - reject if ANY resolve to private addresses + return !addresses.some((ip) => isPrivateIp(ip)); + } catch { + return false; + } +}; diff --git a/api/lib/webhook.ts b/api/lib/webhook.ts new file mode 100644 index 0000000..0790cd3 --- /dev/null +++ b/api/lib/webhook.ts @@ -0,0 +1,106 @@ +import { createHmac } from 'crypto'; +import config from '../config'; +import { getInstanceSettings } from './settings'; + +export type WebhookEvent = 'secret.viewed' | 'secret.burned' | 'apikey.created'; + +interface SecretWebhookData { + secretId: string; + hasPassword: boolean; + hasIpRestriction: boolean; + viewsRemaining?: number; +} + +interface ApiKeyWebhookData { + apiKeyId: string; + name: string; + expiresAt: string | null; + userId: string; +} + +interface WebhookPayload { + event: WebhookEvent; + timestamp: string; + data: SecretWebhookData | ApiKeyWebhookData; +} + +function signPayload(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +async function sendWithRetry( + url: string, + headers: Record, + body: string +): Promise { + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(5000), + redirect: 'error', + }); + + if (response.ok) return; + + if (response.status >= 400 && response.status < 500) { + console.error(`Webhook delivery failed: ${response.status}`); + return; + } + } catch (error) { + if (attempt === maxRetries - 1) { + console.error('Webhook delivery failed after retries:', error); + return; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } +} + +export function sendWebhook(event: WebhookEvent, data: WebhookPayload['data']): void { + (async () => { + try { + const settings = config.isManaged() + ? config.getManagedSettings() + : await getInstanceSettings(); + + if (!settings?.webhookEnabled || !settings.webhookUrl) { + return; + } + + if (event === 'secret.viewed' && !settings.webhookOnView) { + return; + } + if (event === 'secret.burned' && !settings.webhookOnBurn) { + return; + } + + const payload: WebhookPayload = { + event, + timestamp: new Date().toISOString(), + data, + }; + + const payloadString = JSON.stringify(payload); + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Paste-Event': event, + 'User-Agent': 'Paste-ES-Webhook/1.0', + }; + + if (settings.webhookSecret) { + const signature = signPayload(payloadString, settings.webhookSecret); + headers['X-Paste-Signature'] = `sha256=${signature}`; + } + + await sendWithRetry(settings.webhookUrl, headers, payloadString); + } catch (error) { + console.error('Error preparing webhook:', error); + } + })(); +} diff --git a/api/middlewares/auth.ts b/api/middlewares/auth.ts new file mode 100644 index 0000000..9a7e697 --- /dev/null +++ b/api/middlewares/auth.ts @@ -0,0 +1,91 @@ +import { createHash } from 'crypto'; +import { createMiddleware } from 'hono/factory'; +import { auth } from '../auth'; +import prisma from '../lib/db'; + +type Env = { + Variables: { + user: typeof auth.$Infer.Session.user | null; + session: typeof auth.$Infer.Session.session | null; + }; +}; + +export const authMiddleware = createMiddleware(async (c, next) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + await next(); +}); + +export const checkAdmin = createMiddleware(async (c, next) => { + const sessionUser = c.get('user'); + if (!sessionUser) { + return c.json({ error: 'Forbidden' }, 403); + } + + const user = await prisma.user.findUnique({ + where: { id: sessionUser.id }, + select: { role: true }, + }); + + if (!user || user.role !== 'admin') { + return c.json({ error: 'Forbidden' }, 403); + } + await next(); +}); + +// Middleware that accepts either session auth OR API key auth +export const apiKeyOrAuthMiddleware = createMiddleware(async (c, next) => { + // First check if user is already authenticated via session + const sessionUser = c.get('user'); + if (sessionUser) { + return next(); + } + + // Check for API key in Authorization header + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const apiKey = authHeader.substring(7); + if (!apiKey.startsWith('hemmelig_')) { + return c.json({ error: 'Invalid API key format' }, 401); + } + + try { + const keyHash = createHash('sha256').update(apiKey).digest('hex'); + + const apiKeyRecord = await prisma.apiKey.findUnique({ + where: { keyHash }, + include: { user: true }, + }); + + if (!apiKeyRecord) { + return c.json({ error: 'Invalid API key' }, 401); + } + + // Check if key is expired + if (apiKeyRecord.expiresAt && new Date() > apiKeyRecord.expiresAt) { + return c.json({ error: 'API key has expired' }, 401); + } + + // Update last used timestamp (fire and forget) + prisma.apiKey + .update({ + where: { id: apiKeyRecord.id }, + data: { lastUsedAt: new Date() }, + }) + .catch(() => {}); + + // Set user from API key + c.set('user', apiKeyRecord.user as typeof auth.$Infer.Session.user); + c.set('session', null); + + return next(); + } catch (error) { + console.error('API key auth error:', error); + return c.json({ error: 'Authentication failed' }, 401); + } +}); diff --git a/api/middlewares/ip-restriction.ts b/api/middlewares/ip-restriction.ts new file mode 100644 index 0000000..26afe5c --- /dev/null +++ b/api/middlewares/ip-restriction.ts @@ -0,0 +1,33 @@ +import { Context, Next } from 'hono'; +import ipRangeCheck from 'ip-range-check'; +import prisma from '../lib/db'; +import { getClientIp } from '../lib/utils'; + +export const ipRestriction = async (c: Context, next: Next) => { + const { id } = c.req.param(); + + const item = await prisma.secrets.findUnique({ + where: { id }, + select: { + ipRange: true, + }, + }); + + // If no restriction is configured, move on + if (!item?.ipRange) { + return next(); + } + + const ip = getClientIp(c); + + if (!ip) { + return c.json({ error: 'Could not identify client IP' }, 400); + } + + // The core logic is now a single, clean line + if (!ipRangeCheck(ip, item.ipRange)) { + return c.json({ error: 'Access restricted by IP' }, 403); + } + + await next(); +}; diff --git a/api/middlewares/ratelimit.ts b/api/middlewares/ratelimit.ts new file mode 100644 index 0000000..9c4fce7 --- /dev/null +++ b/api/middlewares/ratelimit.ts @@ -0,0 +1,32 @@ +import { Context, Next } from 'hono'; +import { rateLimiter } from 'hono-rate-limiter'; +import settingsCache from '../lib/settings'; +import { getClientIp } from '../lib/utils'; + +let rateLimitInstance: ReturnType | null = null; + +const ratelimit = async (c: Context, next: Next) => { + const instanceSettings = settingsCache.get('instanceSettings'); + + if (instanceSettings?.enableRateLimiting) { + if (rateLimitInstance === null) { + rateLimitInstance = rateLimiter({ + windowMs: instanceSettings.rateLimitWindow * 1000, // Convert seconds to milliseconds + limit: instanceSettings.rateLimitRequests, + standardHeaders: true, + keyGenerator: (c) => getClientIp(c) || 'anonymous', + }); + } + + return rateLimitInstance(c, next); + } + + // If rate limiting is disabled, ensure the limiter is cleared + if (rateLimitInstance !== null) { + rateLimitInstance = null; + } + + await next(); +}; + +export default ratelimit; diff --git a/api/openapi.ts b/api/openapi.ts new file mode 100644 index 0000000..802ce1d --- /dev/null +++ b/api/openapi.ts @@ -0,0 +1,1568 @@ +import { swaggerUI } from '@hono/swagger-ui'; +import { Hono } from 'hono'; + +const openapi = new Hono(); + +const spec = { + openapi: '3.0.3', + info: { + title: 'paste.es API', + description: + 'API for paste.es - a secure secret sharing application. All encryption/decryption happens client-side.', + version: '1.0.0', + contact: { + name: 'CloudHost.es', + url: 'https://cloudhost.es', + }, + }, + servers: [ + { + url: '/api', + description: 'API server', + }, + ], + tags: [ + { name: 'Secrets', description: 'Secret management endpoints' }, + { name: 'Secret Requests', description: 'Request secrets from others' }, + { name: 'Files', description: 'File upload/download endpoints' }, + { name: 'Account', description: 'User account management' }, + { name: 'API Keys', description: 'API key management for programmatic access' }, + { name: 'Instance', description: 'Instance settings' }, + { name: 'Analytics', description: 'Analytics endpoints' }, + { name: 'Invites', description: 'Invite code management' }, + { name: 'Users', description: 'User management (admin)' }, + { name: 'Setup', description: 'Initial setup' }, + { name: 'Health', description: 'Health check' }, + { name: 'Config', description: 'Configuration endpoints' }, + { name: 'Metrics', description: 'Prometheus metrics endpoint' }, + ], + paths: { + '/healthz': { + get: { + tags: ['Health'], + summary: 'Legacy liveness check', + description: + 'Simple liveness check. Kept for backwards compatibility. Consider using /health/live instead.', + responses: { + '200': { + description: 'Service is running', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'healthy' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/health/live': { + get: { + tags: ['Health'], + summary: 'Liveness probe', + description: + 'Simple check to verify the process is running. Use for Kubernetes liveness probes.', + responses: { + '200': { + description: 'Process is alive', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'healthy' }, + timestamp: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/health/ready': { + get: { + tags: ['Health'], + summary: 'Readiness probe', + description: + 'Comprehensive health check verifying database connectivity, file storage, and memory usage. Use for Kubernetes readiness probes.', + responses: { + '200': { + description: 'Service is ready to accept traffic', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/HealthCheckResponse' }, + }, + }, + }, + '503': { + description: 'Service is not ready - one or more checks failed', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/HealthCheckResponse' }, + }, + }, + }, + }, + }, + }, + '/config/social-providers': { + get: { + tags: ['Config'], + summary: 'Get enabled social authentication providers', + responses: { + '200': { + description: 'List of enabled providers', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + providers: { type: 'array', items: { type: 'string' } }, + callbackBaseUrl: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + }, + '/secrets': { + get: { + tags: ['Secrets'], + summary: 'List user secrets', + description: 'Get paginated list of secrets created by the authenticated user', + security: [{ cookieAuth: [] }], + parameters: [ + { + name: 'page', + in: 'query', + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + ], + responses: { + '200': { + description: 'List of secrets', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/SecretListItem' }, + }, + meta: { $ref: '#/components/schemas/PaginationMeta' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + tags: ['Secrets'], + summary: 'Create a new secret', + description: + 'Create a new encrypted secret. The secret content should be encrypted client-side before sending.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateSecretRequest' }, + }, + }, + }, + responses: { + '201': { + description: 'Secret created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '409': { description: 'Conflict - could not create secret' }, + }, + }, + }, + '/secrets/{id}': { + post: { + tags: ['Secrets'], + summary: 'Get a secret', + description: + 'Retrieve an encrypted secret by ID. Atomically consumes a view and burns the secret if burnable and last view. Password required if secret is password-protected.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { password: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Secret data', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/Secret' }, + }, + }, + }, + '401': { description: 'Invalid password' }, + '404': { description: 'Secret not found' }, + }, + }, + delete: { + tags: ['Secrets'], + summary: 'Delete a secret', + description: 'Manually burn/delete a secret', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Secret deleted', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, + '404': { description: 'Secret not found' }, + }, + }, + }, + '/secrets/{id}/check': { + get: { + tags: ['Secrets'], + summary: 'Check secret status', + description: 'Check if a secret exists and whether it requires a password', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'Secret status', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + views: { type: 'integer' }, + title: { type: 'string', nullable: true }, + isPasswordProtected: { type: 'boolean' }, + }, + }, + }, + }, + }, + '404': { description: 'Secret not found' }, + }, + }, + }, + '/secret-requests': { + get: { + tags: ['Secret Requests'], + summary: 'List your secret requests', + description: + 'Get paginated list of secret requests created by the authenticated user', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + parameters: [ + { + name: 'page', + in: 'query', + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + { + name: 'status', + in: 'query', + schema: { + type: 'string', + enum: ['all', 'pending', 'fulfilled', 'expired', 'cancelled'], + }, + }, + ], + responses: { + '200': { + description: 'List of secret requests', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/SecretRequest' }, + }, + meta: { $ref: '#/components/schemas/PaginationMeta' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + tags: ['Secret Requests'], + summary: 'Create a secret request', + description: + 'Create a new secret request. Returns a link to share with the person who will submit the secret.', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/CreateSecretRequestBody' }, + }, + }, + }, + responses: { + '201': { + description: 'Secret request created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + creatorLink: { + type: 'string', + description: 'Link to share with the secret creator', + }, + webhookSecret: { + type: 'string', + nullable: true, + description: 'Webhook signing secret (only shown once)', + }, + expiresAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/secret-requests/{id}': { + get: { + tags: ['Secret Requests'], + summary: 'Get secret request details', + description: 'Get details of a specific secret request (owner only)', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + ], + responses: { + '200': { + description: 'Secret request details', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/SecretRequestDetail' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + '404': { description: 'Secret request not found' }, + }, + }, + delete: { + tags: ['Secret Requests'], + summary: 'Cancel a secret request', + description: 'Cancel a pending secret request (owner only)', + security: [{ cookieAuth: [] }, { bearerAuth: [] }], + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + ], + responses: { + '200': { + description: 'Secret request cancelled', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + }, + }, + }, + }, + }, + '400': { description: 'Can only cancel pending requests' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + '404': { description: 'Secret request not found' }, + }, + }, + }, + '/secret-requests/{id}/info': { + get: { + tags: ['Secret Requests'], + summary: 'Get request info (public)', + description: + 'Get basic info about a secret request. Requires the token from the request link.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + { + name: 'token', + in: 'query', + required: true, + schema: { type: 'string', minLength: 64, maxLength: 64 }, + description: 'Request token from the creator link', + }, + ], + responses: { + '200': { + description: 'Request info', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + '404': { description: 'Invalid or expired request' }, + '410': { description: 'Request already fulfilled or expired' }, + }, + }, + }, + '/secret-requests/{id}/submit': { + post: { + tags: ['Secret Requests'], + summary: 'Submit a secret (public)', + description: + 'Submit an encrypted secret for a request. The secret is encrypted client-side before submission.', + parameters: [ + { + name: 'id', + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + }, + { + name: 'token', + in: 'query', + required: true, + schema: { type: 'string', minLength: 64, maxLength: 64 }, + description: 'Request token from the creator link', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['secret', 'salt'], + properties: { + secret: { + type: 'object', + description: 'Encrypted secret as Uint8Array object', + }, + title: { + type: 'object', + nullable: true, + description: 'Encrypted title as Uint8Array object', + }, + salt: { + type: 'string', + minLength: 16, + maxLength: 64, + description: 'Salt used for encryption', + }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Secret created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + secretId: { + type: 'string', + description: + 'ID of the created secret. Client constructs full URL with decryption key.', + }, + }, + }, + }, + }, + }, + '404': { description: 'Invalid request' }, + '410': { description: 'Request already fulfilled or expired' }, + }, + }, + }, + '/files': { + post: { + tags: ['Files'], + summary: 'Upload a file', + description: 'Upload an encrypted file to attach to a secret', + requestBody: { + required: true, + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'File uploaded', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { id: { type: 'string' } }, + }, + }, + }, + }, + '400': { description: 'Invalid file' }, + '413': { description: 'File too large' }, + }, + }, + }, + '/files/{id}': { + get: { + tags: ['Files'], + summary: 'Download a file', + description: 'Download an encrypted file by ID', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'File content', + content: { 'application/octet-stream': {} }, + }, + '404': { description: 'File not found' }, + }, + }, + }, + '/account': { + get: { + tags: ['Account'], + summary: 'Get account info', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Account information', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + username: { type: 'string' }, + email: { type: 'string' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + put: { + tags: ['Account'], + summary: 'Update account info', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + username: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Account updated' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '409': { description: 'Username already taken' }, + }, + }, + delete: { + tags: ['Account'], + summary: 'Delete account', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Account deleted' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/account/password': { + put: { + tags: ['Account'], + summary: 'Update password', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['currentPassword', 'newPassword'], + properties: { + currentPassword: { type: 'string' }, + newPassword: { type: 'string', minLength: 8 }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Password updated' }, + '400': { description: 'Invalid current password' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/api-keys': { + get: { + tags: ['API Keys'], + summary: 'List API keys', + description: 'Get all API keys for the authenticated user', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of API keys', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/ApiKey' }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + post: { + tags: ['API Keys'], + summary: 'Create API key', + description: 'Create a new API key. The full key is only shown once upon creation.', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + expiresInDays: { type: 'integer', minimum: 1, maximum: 365 }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'API key created', + content: { + 'application/json': { + schema: { + allOf: [ + { $ref: '#/components/schemas/ApiKey' }, + { + type: 'object', + properties: { + key: { + type: 'string', + description: + 'The full API key (only shown once)', + }, + }, + }, + ], + }, + }, + }, + }, + '400': { description: 'Maximum API key limit reached (5)' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + }, + }, + }, + '/api-keys/{id}': { + delete: { + tags: ['API Keys'], + summary: 'Delete API key', + security: [{ cookieAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { description: 'API key deleted' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '404': { description: 'API key not found' }, + }, + }, + }, + '/instance/settings/public': { + get: { + tags: ['Instance'], + summary: 'Get public instance settings', + responses: { + '200': { + description: 'Public settings', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/PublicInstanceSettings' }, + }, + }, + }, + }, + }, + }, + '/instance/settings': { + get: { + tags: ['Instance'], + summary: 'Get all instance settings (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'Instance settings', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InstanceSettings' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + put: { + tags: ['Instance'], + summary: 'Update instance settings (admin)', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InstanceSettings' }, + }, + }, + }, + responses: { + '200': { description: 'Settings updated' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics': { + get: { + tags: ['Analytics'], + summary: 'Get secret analytics (admin)', + security: [{ cookieAuth: [] }], + parameters: [ + { + name: 'timeRange', + in: 'query', + schema: { + type: 'string', + enum: ['7d', '30d', '90d', '1y'], + default: '30d', + }, + }, + ], + responses: { + '200': { description: 'Analytics data' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics/track': { + post: { + tags: ['Analytics'], + summary: 'Track page visit', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['path'], + properties: { path: { type: 'string', maxLength: 255 } }, + }, + }, + }, + }, + responses: { + '201': { description: 'Tracked' }, + '403': { description: 'Analytics disabled or bot detected' }, + }, + }, + }, + '/analytics/visitors': { + get: { + tags: ['Analytics'], + summary: 'Get visitor analytics (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Visitor data' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics/visitors/unique': { + get: { + tags: ['Analytics'], + summary: 'Get unique visitor analytics (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Unique visitor data' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/analytics/visitors/daily': { + get: { + tags: ['Analytics'], + summary: 'Get daily visitor stats (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { description: 'Daily visitor statistics' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/invites': { + get: { + tags: ['Invites'], + summary: 'List invite codes (admin)', + security: [{ cookieAuth: [] }], + responses: { + '200': { + description: 'List of invite codes', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/InviteCode' }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + post: { + tags: ['Invites'], + summary: 'Create invite code (admin)', + security: [{ cookieAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + maxUses: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 1, + }, + expiresInDays: { type: 'integer', minimum: 1, maximum: 365 }, + }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Invite code created', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InviteCode' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/invites/{id}': { + delete: { + tags: ['Invites'], + summary: 'Deactivate invite code (admin)', + security: [{ cookieAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { description: 'Invite code deactivated' }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/invites/public/validate': { + post: { + tags: ['Invites'], + summary: 'Validate invite code', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['code'], + properties: { code: { type: 'string' } }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Validation result', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { valid: { type: 'boolean' } }, + }, + }, + }, + }, + '400': { description: 'Invalid invite code' }, + }, + }, + }, + '/invites/public/use': { + post: { + tags: ['Invites'], + summary: 'Use invite code', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['code', 'userId'], + properties: { + code: { type: 'string' }, + userId: { type: 'string' }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Invite code used' }, + '400': { description: 'Invalid invite code' }, + }, + }, + }, + '/user': { + get: { + tags: ['Users'], + summary: 'List users (admin)', + description: 'Get paginated list of users with optional search', + security: [{ cookieAuth: [] }], + parameters: [ + { + name: 'page', + in: 'query', + schema: { type: 'integer', minimum: 1, default: 1 }, + }, + { + name: 'pageSize', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + { + name: 'search', + in: 'query', + schema: { type: 'string', maxLength: 100 }, + description: 'Search by username, email, or name', + }, + ], + responses: { + '200': { + description: 'Paginated list of users', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + users: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + total: { type: 'integer' }, + page: { type: 'integer' }, + pageSize: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/user/{id}': { + put: { + tags: ['Users'], + summary: 'Update user (admin)', + security: [{ cookieAuth: [] }], + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + username: { type: 'string' }, + email: { type: 'string', format: 'email' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'User updated', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + '401': { $ref: '#/components/responses/Unauthorized' }, + '403': { $ref: '#/components/responses/Forbidden' }, + }, + }, + }, + '/setup/status': { + get: { + tags: ['Setup'], + summary: 'Check if setup is needed', + responses: { + '200': { + description: 'Setup status', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { needsSetup: { type: 'boolean' } }, + }, + }, + }, + }, + }, + }, + }, + '/setup/complete': { + post: { + tags: ['Setup'], + summary: 'Complete initial setup', + description: 'Create the first admin user. Only works when no users exist.', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['email', 'password', 'username', 'name'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string', minLength: 8 }, + username: { type: 'string', minLength: 3, maxLength: 32 }, + name: { type: 'string', minLength: 1, maxLength: 100 }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Setup completed' }, + '403': { description: 'Setup already completed' }, + }, + }, + }, + '/metrics': { + get: { + tags: ['Metrics'], + summary: 'Get Prometheus metrics', + description: + 'Returns metrics in Prometheus exposition format. Requires metrics to be enabled in instance settings. If a metrics secret is configured, Bearer token authentication is required.', + security: [{ metricsAuth: [] }], + responses: { + '200': { + description: 'Prometheus metrics', + content: { + 'text/plain': { + schema: { + type: 'string', + example: + '# HELP hemmelig_secrets_active_count Current number of active (unexpired) secrets\n# TYPE hemmelig_secrets_active_count gauge\nhemmelig_secrets_active_count 42', + }, + }, + }, + }, + '401': { + description: 'Unauthorized - invalid or missing Bearer token', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + '404': { + description: 'Metrics endpoint is disabled', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'better-auth.session_token', + description: 'Session cookie set after authentication via /auth endpoints', + }, + bearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'API key authentication. Use your API key as the bearer token.', + }, + metricsAuth: { + type: 'http', + scheme: 'bearer', + description: + 'Metrics endpoint authentication. Use the configured metrics secret as the bearer token.', + }, + }, + schemas: { + SecretRequest: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + status: { + type: 'string', + enum: ['pending', 'fulfilled', 'expired', 'cancelled'], + }, + maxViews: { type: 'integer' }, + expiresIn: { type: 'integer', description: 'Secret expiration in seconds' }, + webhookUrl: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, + fulfilledAt: { type: 'string', format: 'date-time', nullable: true }, + secretId: { type: 'string', nullable: true }, + }, + }, + SecretRequestDetail: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + title: { type: 'string' }, + description: { type: 'string', nullable: true }, + status: { + type: 'string', + enum: ['pending', 'fulfilled', 'expired', 'cancelled'], + }, + maxViews: { type: 'integer' }, + expiresIn: { type: 'integer' }, + preventBurn: { type: 'boolean' }, + allowedIp: { type: 'string', nullable: true }, + webhookUrl: { type: 'string', nullable: true }, + token: { type: 'string' }, + creatorLink: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, + fulfilledAt: { type: 'string', format: 'date-time', nullable: true }, + secretId: { type: 'string', nullable: true }, + }, + }, + CreateSecretRequestBody: { + type: 'object', + required: ['title', 'expiresIn', 'validFor'], + properties: { + title: { type: 'string', minLength: 1, maxLength: 200 }, + description: { type: 'string', maxLength: 1000 }, + maxViews: { type: 'integer', minimum: 1, maximum: 9999, default: 1 }, + expiresIn: { + type: 'integer', + description: 'How long the created secret lives (seconds)', + enum: [ + 300, 1800, 3600, 14400, 43200, 86400, 259200, 604800, 1209600, 2419200, + ], + }, + validFor: { + type: 'integer', + description: 'How long the request link is valid (seconds)', + enum: [3600, 43200, 86400, 259200, 604800, 1209600, 2592000], + }, + allowedIp: { + type: 'string', + nullable: true, + description: 'IP/CIDR restriction for viewing the secret', + }, + preventBurn: { + type: 'boolean', + default: false, + description: 'Keep secret even after max views reached', + }, + webhookUrl: { + type: 'string', + format: 'uri', + description: 'URL to receive webhook when secret is submitted', + }, + }, + }, + ApiKey: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + keyPrefix: { type: 'string', description: 'First 16 characters of the key' }, + lastUsedAt: { type: 'string', format: 'date-time', nullable: true }, + expiresAt: { type: 'string', format: 'date-time', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + SecretListItem: { + type: 'object', + properties: { + id: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + expiresAt: { type: 'string', format: 'date-time' }, + views: { type: 'integer' }, + isPasswordProtected: { type: 'boolean' }, + ipRange: { type: 'string', nullable: true }, + isBurnable: { type: 'boolean' }, + fileCount: { type: 'integer' }, + }, + }, + Secret: { + type: 'object', + properties: { + id: { type: 'string' }, + secret: { type: 'string', description: 'Encrypted secret content (base64)' }, + title: { type: 'string', nullable: true }, + salt: { type: 'string' }, + views: { type: 'integer' }, + expiresAt: { type: 'string', format: 'date-time' }, + createdAt: { type: 'string', format: 'date-time' }, + isBurnable: { type: 'boolean' }, + ipRange: { type: 'string', nullable: true }, + files: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + filename: { type: 'string' }, + }, + }, + }, + }, + }, + CreateSecretRequest: { + type: 'object', + required: ['secret', 'salt', 'expiresAt'], + properties: { + secret: { type: 'string', description: 'Encrypted secret content' }, + title: { type: 'string', nullable: true }, + salt: { type: 'string', description: 'Salt used for encryption' }, + password: { type: 'string', description: 'Optional password protection' }, + expiresAt: { + type: 'integer', + description: 'Expiration time in seconds from now', + }, + views: { type: 'integer', default: 1, description: 'Number of allowed views' }, + isBurnable: { type: 'boolean', default: false }, + ipRange: { + type: 'string', + nullable: true, + description: 'IP range restriction (CIDR notation)', + }, + fileIds: { + type: 'array', + items: { type: 'string' }, + description: 'IDs of uploaded files to attach', + }, + }, + }, + PaginationMeta: { + type: 'object', + properties: { + total: { type: 'integer' }, + skip: { type: 'integer' }, + take: { type: 'integer' }, + page: { type: 'integer' }, + totalPages: { type: 'integer' }, + }, + }, + PublicInstanceSettings: { + type: 'object', + properties: { + instanceName: { type: 'string' }, + instanceDescription: { type: 'string' }, + allowRegistration: { type: 'boolean' }, + defaultSecretExpiration: { type: 'integer' }, + maxSecretSize: { type: 'integer' }, + allowPasswordProtection: { type: 'boolean' }, + allowIpRestriction: { type: 'boolean' }, + requireRegisteredUser: { type: 'boolean' }, + }, + }, + InstanceSettings: { + type: 'object', + properties: { + instanceName: { type: 'string' }, + instanceDescription: { type: 'string' }, + allowRegistration: { type: 'boolean' }, + requireEmailVerification: { type: 'boolean' }, + defaultSecretExpiration: { type: 'integer' }, + maxSecretSize: { type: 'integer' }, + allowPasswordProtection: { type: 'boolean' }, + allowIpRestriction: { type: 'boolean' }, + enableRateLimiting: { type: 'boolean' }, + rateLimitRequests: { type: 'integer' }, + rateLimitWindow: { type: 'integer' }, + requireInviteCode: { type: 'boolean' }, + allowedEmailDomains: { type: 'string' }, + requireRegisteredUser: { type: 'boolean' }, + webhookEnabled: { type: 'boolean' }, + webhookUrl: { type: 'string' }, + webhookSecret: { type: 'string' }, + webhookOnView: { type: 'boolean' }, + webhookOnBurn: { type: 'boolean' }, + metricsEnabled: { + type: 'boolean', + description: 'Enable Prometheus metrics endpoint', + }, + metricsSecret: { + type: 'string', + description: 'Bearer token for authenticating metrics endpoint requests', + }, + }, + }, + InviteCode: { + type: 'object', + properties: { + id: { type: 'string' }, + code: { type: 'string' }, + maxUses: { type: 'integer' }, + uses: { type: 'integer' }, + expiresAt: { type: 'string', format: 'date-time', nullable: true }, + isActive: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + createdBy: { type: 'string' }, + }, + }, + User: { + type: 'object', + properties: { + id: { type: 'string' }, + username: { type: 'string' }, + email: { type: 'string' }, + role: { type: 'string' }, + banned: { type: 'boolean' }, + createdAt: { type: 'string', format: 'date-time' }, + }, + }, + HealthCheckResponse: { + type: 'object', + properties: { + status: { + type: 'string', + enum: ['healthy', 'unhealthy'], + description: 'Overall health status', + }, + timestamp: { type: 'string', format: 'date-time' }, + checks: { + type: 'object', + properties: { + database: { + type: 'object', + properties: { + status: { type: 'string', enum: ['healthy', 'unhealthy'] }, + latency_ms: { type: 'integer' }, + error: { type: 'string' }, + }, + }, + storage: { + type: 'object', + properties: { + status: { type: 'string', enum: ['healthy', 'unhealthy'] }, + error: { type: 'string' }, + }, + }, + memory: { + type: 'object', + properties: { + status: { type: 'string', enum: ['healthy', 'unhealthy'] }, + heap_used_mb: { type: 'integer' }, + heap_total_mb: { type: 'integer' }, + rss_mb: { type: 'integer' }, + rss_threshold_mb: { type: 'integer' }, + }, + }, + }, + }, + }, + example: { + status: 'healthy', + timestamp: '2024-01-15T10:30:00.000Z', + checks: { + database: { status: 'healthy', latency_ms: 2 }, + storage: { status: 'healthy' }, + memory: { + status: 'healthy', + heap_used_mb: 128, + heap_total_mb: 256, + rss_mb: 312, + rss_threshold_mb: 1024, + }, + }, + }, + }, + }, + responses: { + Unauthorized: { + description: 'Unauthorized - authentication required', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + Forbidden: { + description: 'Forbidden - admin access required', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { error: { type: 'string' } }, + }, + }, + }, + }, + }, + }, +}; + +// OpenAPI JSON spec endpoint +openapi.get('/openapi.json', (c) => c.json(spec)); + +// Swagger UI +openapi.get( + '/docs', + swaggerUI({ + url: '/api/openapi.json', + }) +); + +export default openapi; diff --git a/api/routes.ts b/api/routes.ts new file mode 100644 index 0000000..5d63b40 --- /dev/null +++ b/api/routes.ts @@ -0,0 +1,48 @@ +import { Hono } from 'hono'; +import { getEnabledSocialProviders } from './auth'; +import openapi from './openapi'; +import accountRoute from './routes/account'; +import analyticsRoute from './routes/analytics'; +import apiKeysRoute from './routes/api-keys'; +import filesRoute from './routes/files'; +import healthRoute from './routes/health'; +import instanceRoute from './routes/instance'; +import { invitePublicRoute, inviteRoute } from './routes/invites'; +import metricsRoute from './routes/metrics'; +import secretRequestsRoute from './routes/secret-requests'; +import secretsRoute from './routes/secrets'; +import setupRoute from './routes/setup'; +import { userRoute } from './routes/user'; + +// Create a new router +const routes = new Hono() + .route('/secrets', secretsRoute) + .route('/secret-requests', secretRequestsRoute) + .route('/account', accountRoute) + .route('/files', filesRoute) + .route('/user', userRoute) + .route('/instance', instanceRoute) + .route('/analytics', analyticsRoute) + .route('/invites/public', invitePublicRoute) + .route('/invites', inviteRoute) + .route('/setup', setupRoute) + .route('/api-keys', apiKeysRoute) + .route('/metrics', metricsRoute) + .route('/health', healthRoute) + .route('/', openapi) + // Legacy liveness endpoint (kept for backwards compatibility) + .get('/healthz', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString() })) + .get('/config/social-providers', (c) => { + const providers = getEnabledSocialProviders(); + const baseUrl = process.env.HEMMELIG_BASE_URL || c.req.header('origin') || ''; + const callbackBaseUrl = baseUrl ? `${baseUrl}/api/auth/callback` : ''; + + return c.json({ + providers, + callbackBaseUrl, + }); + }); + +export default routes; + +export type AppType = typeof routes; diff --git a/api/routes/account.ts b/api/routes/account.ts new file mode 100644 index 0000000..dd81489 --- /dev/null +++ b/api/routes/account.ts @@ -0,0 +1,130 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { handleNotFound } from '../lib/utils'; +import { authMiddleware } from '../middlewares/auth'; +import { updateAccountSchema, updatePasswordSchema } from '../validations/account'; + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>(); + +// Get user account information +app.get('/', authMiddleware, async (c) => { + const user = c.get('user'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + return c.json({ + username: user.username, + email: user.email, + }); +}); + +// Update user account information +app.put('/', authMiddleware, zValidator('json', updateAccountSchema), async (c) => { + const user = c.get('user'); + const { username, email } = c.req.valid('json'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + // Check if username is taken by another user + if (username) { + const existingUser = await prisma.user.findUnique({ + where: { username }, + select: { id: true }, + }); + if (existingUser && existingUser.id !== user.id) { + return c.json({ error: 'Username is already taken' }, 409); + } + } + + // Check if email is taken by another user + if (email) { + const existingEmail = await prisma.user.findFirst({ + where: { email }, + select: { id: true }, + }); + if (existingEmail && existingEmail.id !== user.id) { + return c.json({ error: 'Email is already taken' }, 409); + } + } + + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { + username, + email, + }, + }); + + return c.json({ + username: updatedUser.username, + email: updatedUser.email, + }); + } catch (error) { + console.error('Failed to update account:', error); + return handleNotFound(error as Error & { code?: string }, c); + } +}); + +// Update user password +app.put('/password', authMiddleware, zValidator('json', updatePasswordSchema), async (c) => { + const user = c.get('user'); + const { currentPassword, newPassword } = c.req.valid('json'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + // Use better-auth's changePassword API + const result = await auth.api.changePassword({ + body: { + currentPassword, + newPassword, + }, + headers: c.req.raw.headers, + }); + + if (!result) { + return c.json({ error: 'Failed to change password' }, 500); + } + + return c.json({ message: 'Password updated successfully' }); + } catch (error) { + console.error('Failed to update password:', error); + const message = error instanceof Error ? error.message : 'Failed to update password'; + return c.json({ error: message }, 500); + } +}); + +// Delete user account +app.delete('/', authMiddleware, async (c) => { + const user = c.get('user'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + await prisma.user.delete({ + where: { id: user.id }, + }); + + return c.json({ message: 'Account deleted successfully' }); + } catch (error) { + console.error('Failed to delete account:', error); + return handleNotFound(error as Error & { code?: string }, c); + } +}); + +export default app; diff --git a/api/routes/analytics.ts b/api/routes/analytics.ts new file mode 100644 index 0000000..b03e78c --- /dev/null +++ b/api/routes/analytics.ts @@ -0,0 +1,253 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { isbot } from 'isbot'; +import { z } from 'zod'; +import { + calculatePercentage, + createVisitorId, + getStartDateForTimeRange, + isAnalyticsEnabled, + isValidAnalyticsPath, +} from '../lib/analytics'; +import prisma from '../lib/db'; +import { getClientIp } from '../lib/utils'; +import { authMiddleware, checkAdmin } from '../middlewares/auth'; + +const app = new Hono(); + +const trackSchema = z.object({ + path: z.string().max(255), +}); + +const timeRangeSchema = z.object({ + timeRange: z.enum(['7d', '14d', '30d']).default('30d'), +}); + +// POST /api/analytics/track - Public endpoint for visitor tracking +app.post('/track', zValidator('json', trackSchema), async (c) => { + if (!isAnalyticsEnabled()) { + return c.json({ success: false }, 403); + } + + const userAgent = c.req.header('user-agent') || ''; + + if (isbot(userAgent)) { + return c.json({ success: false }, 403); + } + + try { + const { path } = c.req.valid('json'); + + if (!isValidAnalyticsPath(path)) { + return c.json({ error: 'Invalid path format' }, 400); + } + + const uniqueId = createVisitorId(getClientIp(c), userAgent); + + await prisma.visitorAnalytics.create({ + data: { path, uniqueId }, + }); + + return c.json({ success: true }, 201); + } catch (error) { + console.error('Analytics tracking error:', error); + return c.json({ error: 'Failed to track analytics' }, 500); + } +}); + +// GET /api/analytics - Secret analytics (admin only) +app.get('/', authMiddleware, checkAdmin, zValidator('query', timeRangeSchema), async (c) => { + const { timeRange } = c.req.valid('query'); + const now = new Date(); + const startDate = getStartDateForTimeRange(timeRange); + + try { + // Use aggregations for basic counts - much more efficient than loading all records + const [aggregates, activeCount, typesCounts, dailyStats, secretRequestStats] = + await Promise.all([ + // Get total count and sum of views + prisma.secrets.aggregate({ + where: { createdAt: { gte: startDate } }, + _count: true, + _sum: { views: true }, + }), + // Count active (non-expired) secrets + prisma.secrets.count({ + where: { + createdAt: { gte: startDate }, + expiresAt: { gt: now }, + }, + }), + // Get counts for secret types in parallel + Promise.all([ + prisma.secrets.count({ + where: { createdAt: { gte: startDate }, password: { not: null } }, + }), + prisma.secrets.count({ + where: { + createdAt: { gte: startDate }, + ipRange: { not: null }, + NOT: { ipRange: '' }, + }, + }), + prisma.secrets.count({ + where: { createdAt: { gte: startDate }, isBurnable: true }, + }), + ]), + // For daily stats, we still need individual records but only select minimal fields + prisma.secrets.findMany({ + where: { createdAt: { gte: startDate } }, + select: { + createdAt: true, + views: true, + expiresAt: true, + }, + }), + // Secret request statistics + Promise.all([ + prisma.secretRequest.count({ + where: { createdAt: { gte: startDate } }, + }), + prisma.secretRequest.count({ + where: { createdAt: { gte: startDate }, status: 'fulfilled' }, + }), + ]), + ]); + + const totalSecrets = aggregates._count; + const totalViews = aggregates._sum.views || 0; + const activeSecrets = activeCount; + const expiredSecrets = totalSecrets - activeSecrets; + const averageViews = totalSecrets > 0 ? totalViews / totalSecrets : 0; + + const [passwordProtected, ipRestricted, burnable] = typesCounts; + const [totalSecretRequests, fulfilledSecretRequests] = secretRequestStats; + + // Process daily stats from minimal data + const dailyStatsMap = dailyStats.reduce( + (acc, secret) => { + const date = secret.createdAt.toISOString().split('T')[0]; + if (!acc[date]) { + acc[date] = { date, secrets: 0, views: 0 }; + } + acc[date].secrets++; + acc[date].views += secret.views || 0; + return acc; + }, + {} as Record + ); + + // Calculate expiration stats from minimal data + const expirationDurations = dailyStats.map( + (s) => (s.expiresAt.getTime() - s.createdAt.getTime()) / (1000 * 60 * 60) + ); + const oneHour = expirationDurations.filter((d) => d <= 1).length; + const oneDay = expirationDurations.filter((d) => d > 1 && d <= 24).length; + const oneWeekPlus = expirationDurations.filter((d) => d > 24).length; + + return c.json({ + totalSecrets, + totalViews, + activeSecrets, + expiredSecrets, + averageViews: parseFloat(averageViews.toFixed(2)), + dailyStats: Object.values(dailyStatsMap), + secretTypes: { + passwordProtected: calculatePercentage(passwordProtected, totalSecrets), + ipRestricted: calculatePercentage(ipRestricted, totalSecrets), + burnable: calculatePercentage(burnable, totalSecrets), + }, + expirationStats: { + oneHour: calculatePercentage(oneHour, totalSecrets), + oneDay: calculatePercentage(oneDay, totalSecrets), + oneWeekPlus: calculatePercentage(oneWeekPlus, totalSecrets), + }, + secretRequests: { + total: totalSecretRequests, + fulfilled: fulfilledSecretRequests, + }, + }); + } catch (error) { + console.error('Failed to fetch analytics data:', error); + return c.json({ error: 'Failed to fetch analytics data' }, 500); + } +}); + +// GET /api/analytics/visitors - Visitor analytics data (admin only) +app.get('/visitors', authMiddleware, checkAdmin, async (c) => { + try { + const analytics = await prisma.visitorAnalytics.findMany({ + orderBy: { timestamp: 'desc' }, + take: 1000, + }); + return c.json(analytics); + } catch (error) { + console.error('Analytics retrieval error:', error); + return c.json({ error: 'Failed to retrieve analytics' }, 500); + } +}); + +// GET /api/analytics/visitors/unique - Aggregated unique visitor data (admin only) +app.get('/visitors/unique', authMiddleware, checkAdmin, async (c) => { + try { + const aggregatedData = await prisma.visitorAnalytics.groupBy({ + by: ['uniqueId', 'path'], + _count: { uniqueId: true }, + orderBy: { _count: { uniqueId: 'desc' } }, + }); + return c.json(aggregatedData); + } catch (error) { + console.error('Aggregated analytics retrieval error:', error); + return c.json({ error: 'Failed to retrieve aggregated analytics' }, 500); + } +}); + +// GET /api/analytics/visitors/daily - Daily visitor statistics (admin only) +app.get( + '/visitors/daily', + authMiddleware, + checkAdmin, + zValidator('query', timeRangeSchema), + async (c) => { + try { + const { timeRange } = c.req.valid('query'); + const startDate = getStartDateForTimeRange(timeRange); + + // Use raw SQL for efficient database-level aggregation + // This avoids loading all records into memory for high-traffic instances + const aggregatedData = await prisma.$queryRaw< + Array<{ + date: string; + unique_visitors: bigint; + total_visits: bigint; + paths: string; + }> + >` + SELECT + DATE(timestamp) as date, + COUNT(DISTINCT uniqueId) as unique_visitors, + COUNT(*) as total_visits, + GROUP_CONCAT(DISTINCT path) as paths + FROM visitor_analytics + WHERE timestamp >= ${startDate} + GROUP BY DATE(timestamp) + ORDER BY date ASC + `; + + // Convert BigInt to number for JSON serialization + const result = aggregatedData.map((row) => ({ + date: row.date, + unique_visitors: Number(row.unique_visitors), + total_visits: Number(row.total_visits), + paths: row.paths || '', + })); + + return c.json(result); + } catch (error) { + console.error('Daily analytics retrieval error:', error); + return c.json({ error: 'Failed to retrieve daily analytics' }, 500); + } + } +); + +export default app; diff --git a/api/routes/api-keys.ts b/api/routes/api-keys.ts new file mode 100644 index 0000000..145442d --- /dev/null +++ b/api/routes/api-keys.ts @@ -0,0 +1,156 @@ +import { zValidator } from '@hono/zod-validator'; +import { createHash, randomBytes } from 'crypto'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { handleNotFound } from '../lib/utils'; +import { sendWebhook } from '../lib/webhook'; +import { authMiddleware } from '../middlewares/auth'; + +const createApiKeySchema = z.object({ + name: z.string().min(1).max(100), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); + +const deleteApiKeySchema = z.object({ + id: z.string(), +}); + +function hashApiKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} + +function generateApiKey(): string { + const prefix = 'hemmelig'; + const key = randomBytes(24).toString('base64url'); + return `${prefix}_${key}`; +} + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + .use(authMiddleware) + .get('/', async (c) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const apiKeys = await prisma.apiKey.findMany({ + where: { userId: user.id }, + select: { + id: true, + name: true, + keyPrefix: true, + lastUsedAt: true, + expiresAt: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + return c.json(apiKeys); + } catch (error) { + console.error('Failed to list API keys:', error); + return c.json({ error: 'Failed to list API keys' }, 500); + } + }) + .post('/', zValidator('json', createApiKeySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const { name, expiresInDays } = c.req.valid('json'); + + try { + // Check API key limit (max 5 per user) + const existingCount = await prisma.apiKey.count({ + where: { userId: user.id }, + }); + + if (existingCount >= 5) { + return c.json({ error: 'Maximum API key limit reached (5)' }, 400); + } + + const rawKey = generateApiKey(); + const keyHash = hashApiKey(rawKey); + const keyPrefix = rawKey.substring(0, 16); + + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) + : null; + + const apiKey = await prisma.apiKey.create({ + data: { + name, + keyHash, + keyPrefix, + userId: user.id, + expiresAt, + }, + select: { + id: true, + name: true, + keyPrefix: true, + expiresAt: true, + createdAt: true, + }, + }); + + // Send webhook for API key creation + sendWebhook('apikey.created', { + apiKeyId: apiKey.id, + name: apiKey.name, + expiresAt: apiKey.expiresAt?.toISOString() || null, + userId: user.id, + }); + + // Return the raw key only once - it cannot be retrieved again + return c.json( + { + ...apiKey, + key: rawKey, + }, + 201 + ); + } catch (error) { + console.error('Failed to create API key:', error); + return c.json({ error: 'Failed to create API key' }, 500); + } + }) + .delete('/:id', zValidator('param', deleteApiKeySchema), async (c) => { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const { id } = c.req.valid('param'); + + try { + // Ensure the API key belongs to the user + const apiKey = await prisma.apiKey.findFirst({ + where: { id, userId: user.id }, + }); + + if (!apiKey) { + return c.json({ error: 'API key not found' }, 404); + } + + await prisma.apiKey.delete({ where: { id } }); + + return c.json({ success: true }); + } catch (error) { + console.error('Failed to delete API key:', error); + return handleNotFound(error as Error & { code?: string }, c); + } + }); + +export default app; + +// Export helper for middleware +export { hashApiKey }; diff --git a/api/routes/files.ts b/api/routes/files.ts new file mode 100644 index 0000000..175ad6e --- /dev/null +++ b/api/routes/files.ts @@ -0,0 +1,134 @@ +import { zValidator } from '@hono/zod-validator'; +import { createReadStream, createWriteStream } from 'fs'; +import { Hono } from 'hono'; +import { stream } from 'hono/streaming'; +import { nanoid } from 'nanoid'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; +import { z } from 'zod'; +import config from '../config'; +import prisma from '../lib/db'; +import { generateSafeFilePath, getMaxFileSize, isPathSafe } from '../lib/files'; +import { getInstanceSettings } from '../lib/settings'; + +const files = new Hono(); + +const fileIdParamSchema = z.object({ + id: z.string(), +}); + +files.get('/:id', zValidator('param', fileIdParamSchema), async (c) => { + const { id } = c.req.valid('param'); + + try { + // Fetch file with its associated secrets to verify access + const file = await prisma.file.findUnique({ + where: { id }, + include: { + secrets: { + select: { + id: true, + views: true, + expiresAt: true, + }, + }, + }, + }); + + if (!file) { + return c.json({ error: 'File not found' }, 404); + } + + // Security: Verify the file is associated with at least one valid (non-expired, has views) secret + // This prevents direct file access without going through the secret viewing flow + const hasValidSecret = file.secrets.some((secret) => { + const now = new Date(); + const hasViewsRemaining = secret.views === null || secret.views > 0; + const notExpired = secret.expiresAt > now; + return hasViewsRemaining && notExpired; + }); + + if (!hasValidSecret) { + return c.json({ error: 'File not found' }, 404); + } + + // Validate path is within upload directory to prevent path traversal + if (!isPathSafe(file.path)) { + console.error(`Path traversal attempt detected: ${file.path}`); + return c.json({ error: 'File not found' }, 404); + } + + // Stream the file instead of loading it entirely into memory + const nodeStream = createReadStream(file.path); + const webStream = Readable.toWeb(nodeStream) as ReadableStream; + + return stream(c, async (s) => { + s.onAbort(() => { + nodeStream.destroy(); + }); + await s.pipe(webStream); + }); + } catch (error) { + console.error('Failed to download file:', error); + return c.json({ error: 'Failed to download file' }, 500); + } +}); + +files.post('/', async (c) => { + try { + // Check if file uploads are allowed + let allowFileUploads = true; + if (config.isManaged()) { + const managedSettings = config.getManagedSettings(); + allowFileUploads = managedSettings?.allowFileUploads ?? true; + } else { + const instanceSettings = await getInstanceSettings(); + allowFileUploads = instanceSettings?.allowFileUploads ?? true; + } + + if (!allowFileUploads) { + return c.json({ error: 'File uploads are disabled on this instance.' }, 403); + } + + const body = await c.req.parseBody(); + const file = body['file']; + + if (!(file instanceof File)) { + return c.json({ error: 'File is required and must be a file.' }, 400); + } + + const maxFileSize = getMaxFileSize(); + if (file.size > maxFileSize) { + return c.json( + { error: `File size exceeds the limit of ${maxFileSize / 1024 / 1024}MB.` }, + 413 + ); + } + + const id = nanoid(); + const safePath = generateSafeFilePath(id, file.name); + + if (!safePath) { + console.error(`Path traversal attempt in upload: ${file.name}`); + return c.json({ error: 'Invalid filename' }, 400); + } + + // Stream the file to disk instead of loading it entirely into memory + const webStream = file.stream(); + const nodeStream = Readable.fromWeb(webStream as import('stream/web').ReadableStream); + const writeStream = createWriteStream(safePath.path); + + await pipeline(nodeStream, writeStream); + + const newFile = await prisma.file.create({ + data: { id, filename: safePath.filename, path: safePath.path }, + }); + + return c.json({ id: newFile.id }, 201); + } catch (error) { + console.error('Failed to upload file:', error); + return c.json({ error: 'Failed to upload file' }, 500); + } +}); + +export default files; diff --git a/api/routes/health.ts b/api/routes/health.ts new file mode 100644 index 0000000..ac0adbb --- /dev/null +++ b/api/routes/health.ts @@ -0,0 +1,131 @@ +import { constants } from 'fs'; +import { access, unlink, writeFile } from 'fs/promises'; +import { Hono } from 'hono'; +import { join } from 'path'; +import prisma from '../lib/db'; +import { UPLOAD_DIR } from '../lib/files'; + +const app = new Hono(); + +type CheckStatus = 'healthy' | 'unhealthy'; + +type CheckResult = { + status: CheckStatus; + latency_ms?: number; + error?: string; + [key: string]: unknown; +}; + +type HealthResponse = { + status: CheckStatus; + timestamp: string; + checks: { + database: CheckResult; + storage: CheckResult; + memory: CheckResult; + }; +}; + +/** + * Check database connectivity by executing a simple query + */ +async function checkDatabase(): Promise { + const start = Date.now(); + try { + await prisma.$queryRaw`SELECT 1`; + return { + status: 'healthy', + latency_ms: Date.now() - start, + }; + } catch (error) { + return { + status: 'unhealthy', + latency_ms: Date.now() - start, + error: error instanceof Error ? error.message : 'Database connection failed', + }; + } +} + +/** + * Check file storage is accessible and writable + */ +async function checkStorage(): Promise { + const testFile = join(UPLOAD_DIR, `.health-check-${Date.now()}`); + try { + // Check directory exists and is accessible + await access(UPLOAD_DIR, constants.R_OK | constants.W_OK); + + // Try to write and delete a test file + await writeFile(testFile, 'health-check'); + await unlink(testFile); + + return { + status: 'healthy', + }; + } catch (error) { + return { + status: 'unhealthy', + error: error instanceof Error ? error.message : 'Storage check failed', + }; + } +} + +/** + * Check memory usage is within acceptable bounds + * Note: heapUsed/heapTotal ratio is often high (90%+) in normal Node.js operation + * since the heap grows dynamically. We use RSS-based threshold instead. + */ +function checkMemory(): CheckResult { + const memUsage = process.memoryUsage(); + const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024); + const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024); + const rssMB = Math.round(memUsage.rss / 1024 / 1024); + + // Consider unhealthy if RSS exceeds 1GB (reasonable default for most deployments) + const RSS_THRESHOLD_MB = 1024; + const isHealthy = rssMB < RSS_THRESHOLD_MB; + + return { + status: isHealthy ? 'healthy' : 'unhealthy', + heap_used_mb: heapUsedMB, + heap_total_mb: heapTotalMB, + rss_mb: rssMB, + rss_threshold_mb: RSS_THRESHOLD_MB, + }; +} + +/** + * GET /health/live - Liveness probe + * Simple check to verify the process is running + */ +app.get('/live', (c) => { + return c.json({ status: 'healthy', timestamp: new Date().toISOString() }); +}); + +/** + * GET /health/ready - Readiness probe + * Comprehensive check of all dependencies + */ +app.get('/ready', async (c) => { + const [database, storage] = await Promise.all([checkDatabase(), checkStorage()]); + + const memory = checkMemory(); + + const checks = { database, storage, memory }; + + const overallStatus: CheckStatus = Object.values(checks).every( + (check) => check.status === 'healthy' + ) + ? 'healthy' + : 'unhealthy'; + + const response: HealthResponse = { + status: overallStatus, + timestamp: new Date().toISOString(), + checks, + }; + + return c.json(response, overallStatus === 'healthy' ? 200 : 503); +}); + +export default app; diff --git a/api/routes/instance.ts b/api/routes/instance.ts new file mode 100644 index 0000000..de8a870 --- /dev/null +++ b/api/routes/instance.ts @@ -0,0 +1,169 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import config from '../config'; +import { ADMIN_SETTINGS_FIELDS, PUBLIC_SETTINGS_FIELDS } from '../lib/constants'; +import prisma from '../lib/db'; +import settingsCache, { setCachedInstanceSettings } from '../lib/settings'; +import { handleNotFound, isPublicUrl } from '../lib/utils'; +import { authMiddleware, checkAdmin } from '../middlewares/auth'; +import { instanceSettingsSchema } from '../validations/instance'; + +const app = new Hono(); + +// GET /api/instance/managed - check if instance is in managed mode +app.get('/managed', async (c) => { + return c.json({ managed: config.isManaged() }); +}); + +// GET /api/instance/settings/public - public settings for all users +app.get('/settings/public', async (c) => { + try { + // In managed mode, return settings from environment variables + if (config.isManaged()) { + const managedSettings = config.getManagedSettings(); + const publicSettings = Object.fromEntries( + Object.entries(managedSettings || {}).filter( + ([key]) => key in PUBLIC_SETTINGS_FIELDS + ) + ); + return c.json(publicSettings); + } + + let dbSettings = await prisma.instanceSettings.findFirst({ + select: PUBLIC_SETTINGS_FIELDS, + }); + + if (!dbSettings) { + const initialData = { + ...Object.fromEntries( + Object.entries(config.get('general')).filter(([, v]) => v !== undefined) + ), + ...Object.fromEntries( + Object.entries(config.get('security')).filter(([, v]) => v !== undefined) + ), + }; + + dbSettings = await prisma.instanceSettings.create({ + data: initialData, + select: PUBLIC_SETTINGS_FIELDS, + }); + } + + const configSettings = { + ...config.get('general'), + ...config.get('security'), + }; + const filteredConfigSettings = Object.fromEntries( + Object.entries(configSettings).filter( + ([key, value]) => value !== undefined && key in PUBLIC_SETTINGS_FIELDS + ) + ); + + const finalSettings = { + ...dbSettings, + ...filteredConfigSettings, + }; + + return c.json(finalSettings); + } catch (error) { + console.error('Failed to fetch public instance settings:', error); + return c.json({ error: 'Failed to fetch instance settings' }, 500); + } +}); + +// GET /api/instance/settings - admin only +app.get('/settings', authMiddleware, checkAdmin, async (c) => { + try { + // In managed mode, return settings from environment variables + if (config.isManaged()) { + const managedSettings = config.getManagedSettings(); + return c.json(managedSettings); + } + + let dbSettings = await prisma.instanceSettings.findFirst({ select: ADMIN_SETTINGS_FIELDS }); + + if (!dbSettings) { + const initialData = { + ...Object.fromEntries( + Object.entries(config.get('general')).filter(([, v]) => v !== undefined) + ), + ...Object.fromEntries( + Object.entries(config.get('security')).filter(([, v]) => v !== undefined) + ), + }; + + dbSettings = await prisma.instanceSettings.create({ + data: initialData, + select: ADMIN_SETTINGS_FIELDS, + }); + } + + const configSettings = { + ...config.get('general'), + ...config.get('security'), + }; + const filteredConfigSettings = Object.fromEntries( + Object.entries(configSettings).filter(([, value]) => value !== undefined) + ); + + const finalSettings = { + ...dbSettings, + ...filteredConfigSettings, + }; + + return c.json(finalSettings); + } catch (error) { + console.error('Failed to fetch instance settings:', error); + return c.json({ error: 'Failed to fetch instance settings' }, 500); + } +}); + +// PUT /api/instance/settings +app.put( + '/settings', + authMiddleware, + checkAdmin, + zValidator('json', instanceSettingsSchema), + async (c) => { + // Block updates in managed mode + if (config.isManaged()) { + return c.json( + { error: 'Instance is in managed mode. Settings cannot be modified.' }, + 403 + ); + } + + const body = c.req.valid('json'); + + if (body.webhookUrl && body.webhookUrl !== '' && !(await isPublicUrl(body.webhookUrl))) { + return c.json({ error: 'Webhook URL cannot point to private/internal addresses' }, 400); + } + + try { + const settings = await prisma.instanceSettings.findFirst(); + + if (!settings) { + return c.json({ error: 'Instance settings not found' }, 404); + } + + const updatedSettings = await prisma.instanceSettings.update({ + where: { id: settings.id }, + data: body, + select: ADMIN_SETTINGS_FIELDS, + }); + + const currentSettings = settingsCache.get('instanceSettings'); + setCachedInstanceSettings({ + ...currentSettings, + ...updatedSettings, + }); + + return c.json(updatedSettings); + } catch (error) { + console.error('Failed to update instance settings:', error); + return handleNotFound(error as Error & { code?: string }, c); + } + } +); + +export default app; diff --git a/api/routes/invites.ts b/api/routes/invites.ts new file mode 100644 index 0000000..97713b7 --- /dev/null +++ b/api/routes/invites.ts @@ -0,0 +1,151 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import { auth } from '../auth'; +import { TIME } from '../lib/constants'; +import prisma from '../lib/db'; +import { handleNotFound } from '../lib/utils'; +import { authMiddleware, checkAdmin } from '../middlewares/auth'; + +const createInviteSchema = z.object({ + maxUses: z.number().int().min(1).max(100).optional().default(1), + expiresInDays: z.number().int().min(1).max(365).optional(), +}); + +const codeSchema = z.object({ code: z.string() }); + +// Public route for validating invite codes (no auth required) +export const invitePublicRoute = new Hono() + .post('/validate', zValidator('json', codeSchema), async (c) => { + const { code } = c.req.valid('json'); + + try { + const invite = await prisma.inviteCode.findUnique({ + where: { code: code.toUpperCase() }, + }); + + if (!invite || !invite.isActive) { + return c.json({ error: 'Invalid invite code' }, 400); + } + + if (invite.expiresAt && new Date() > invite.expiresAt) { + return c.json({ error: 'Invite code has expired' }, 400); + } + + if (invite.maxUses && invite.uses >= invite.maxUses) { + return c.json({ error: 'Invite code has reached maximum uses' }, 400); + } + + return c.json({ valid: true }); + } catch (error) { + console.error('Failed to validate invite code:', error); + return c.json({ error: 'Failed to validate invite code' }, 500); + } + }) + .post('/use', zValidator('json', z.object({ code: z.string() })), async (c) => { + const { code } = c.req.valid('json'); + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + const userId = user.id; + + try { + const invite = await prisma.inviteCode.findUnique({ + where: { code: code.toUpperCase() }, + }); + + if (!invite || !invite.isActive) { + return c.json({ error: 'Invalid invite code' }, 400); + } + + if (invite.expiresAt && new Date() > invite.expiresAt) { + return c.json({ error: 'Invite code has expired' }, 400); + } + + if (invite.maxUses && invite.uses >= invite.maxUses) { + return c.json({ error: 'Invite code has reached maximum uses' }, 400); + } + + await prisma.$transaction([ + prisma.inviteCode.update({ + where: { id: invite.id }, + data: { uses: { increment: 1 } }, + }), + prisma.user.update({ + where: { id: userId }, + data: { inviteCodeUsed: code.toUpperCase() }, + }), + ]); + + return c.json({ success: true }); + } catch (error) { + console.error('Failed to use invite code:', error); + return c.json({ error: 'Failed to use invite code' }, 500); + } + }); + +// Protected routes for admin invite management +export const inviteRoute = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + .use(authMiddleware) + .use(checkAdmin) + .get('/', async (c) => { + try { + const invites = await prisma.inviteCode.findMany({ + orderBy: { createdAt: 'desc' }, + }); + return c.json(invites); + } catch (error) { + console.error('Failed to list invite codes:', error); + return c.json({ error: 'Failed to list invite codes' }, 500); + } + }) + .post('/', zValidator('json', createInviteSchema), async (c) => { + const { maxUses, expiresInDays } = c.req.valid('json'); + const user = c.get('user'); + + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + try { + const code = nanoid(12).toUpperCase(); + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * TIME.DAY_MS) + : null; + + const invite = await prisma.inviteCode.create({ + data: { + code, + maxUses, + expiresAt, + createdBy: user.id, + }, + }); + + return c.json(invite, 201); + } catch (error) { + console.error('Failed to create invite code:', error); + return c.json({ error: 'Failed to create invite code' }, 500); + } + }) + .delete('/:id', zValidator('param', z.object({ id: z.string() })), async (c) => { + const { id } = c.req.valid('param'); + + try { + await prisma.inviteCode.update({ + where: { id }, + data: { isActive: false }, + }); + + return c.json({ success: true }); + } catch (error) { + console.error(`Failed to delete invite code ${id}:`, error); + return handleNotFound(error as Error & { code?: string }, c); + } + }); diff --git a/api/routes/metrics.ts b/api/routes/metrics.ts new file mode 100644 index 0000000..b619c67 --- /dev/null +++ b/api/routes/metrics.ts @@ -0,0 +1,159 @@ +import { timingSafeEqual } from 'crypto'; +import { Hono } from 'hono'; +import { collectDefaultMetrics, Gauge, Histogram, register, Registry } from 'prom-client'; +import config from '../config'; +import prisma from '../lib/db'; +import { getInstanceSettings } from '../lib/settings'; + +const app = new Hono(); + +// Create a custom registry +const metricsRegistry = new Registry(); + +// Collect default Node.js metrics (memory, CPU, event loop, etc.) +collectDefaultMetrics({ register: metricsRegistry }); + +// Custom application metrics +const activeSecretsGauge = new Gauge({ + name: 'hemmelig_secrets_active_count', + help: 'Current number of active (unexpired) secrets', + registers: [metricsRegistry], +}); + +const totalUsersGauge = new Gauge({ + name: 'hemmelig_users_total', + help: 'Total number of registered users', + registers: [metricsRegistry], +}); + +const visitorsUnique30dGauge = new Gauge({ + name: 'hemmelig_visitors_unique_30d', + help: 'Unique visitors in the last 30 days', + registers: [metricsRegistry], +}); + +const visitorsViews30dGauge = new Gauge({ + name: 'hemmelig_visitors_views_30d', + help: 'Total page views in the last 30 days', + registers: [metricsRegistry], +}); + +const httpRequestDuration = new Histogram({ + name: 'hemmelig_http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], + buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], + registers: [metricsRegistry], +}); + +// Function to update gauge metrics from database +async function updateGaugeMetrics() { + try { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + // Count active secrets (not expired) + const activeSecrets = await prisma.secrets.count({ + where: { + expiresAt: { + gt: now, + }, + }, + }); + activeSecretsGauge.set(activeSecrets); + + // Count total users + const totalUsers = await prisma.user.count(); + totalUsersGauge.set(totalUsers); + + // Get visitor stats for the last 30 days + const visitorStats = await prisma.$queryRaw< + Array<{ unique_visitors: bigint; total_views: bigint }> + >` + SELECT + COUNT(DISTINCT uniqueId) as unique_visitors, + COUNT(*) as total_views + FROM visitor_analytics + WHERE timestamp >= ${thirtyDaysAgo} + `; + + if (visitorStats.length > 0) { + visitorsUnique30dGauge.set(Number(visitorStats[0].unique_visitors)); + visitorsViews30dGauge.set(Number(visitorStats[0].total_views)); + } + } catch (error) { + console.error('Failed to update metrics gauges:', error); + } +} + +// Helper function to verify Bearer token using constant-time comparison +function verifyBearerToken(authHeader: string | undefined, expectedSecret: string): boolean { + if (!authHeader || !expectedSecret) { + return false; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + return false; + } + + const provided = Buffer.from(parts[1]); + const expected = Buffer.from(expectedSecret); + + // Pad to same length to prevent timing leaks on token length + const maxLen = Math.max(provided.length, expected.length); + const paddedProvided = Buffer.alloc(maxLen); + const paddedExpected = Buffer.alloc(maxLen); + provided.copy(paddedProvided); + expected.copy(paddedExpected); + + // Constant-time comparison to prevent timing attacks + return timingSafeEqual(paddedProvided, paddedExpected) && provided.length === expected.length; +} + +// GET /api/metrics - Prometheus metrics endpoint +app.get('/', async (c) => { + try { + // In managed mode, use environment-based settings; otherwise use database + const settings = config.isManaged() + ? config.getManagedSettings() + : await getInstanceSettings(); + + // Check if metrics are enabled + if (!settings?.metricsEnabled) { + return c.json({ error: 'Metrics endpoint is disabled' }, 404); + } + + // Verify authentication if secret is configured + if (settings.metricsSecret) { + const authHeader = c.req.header('Authorization'); + if (!verifyBearerToken(authHeader, settings.metricsSecret)) { + return c.json({ error: 'Unauthorized' }, 401); + } + } + + // Update gauge metrics before returning + await updateGaugeMetrics(); + + // Get metrics in Prometheus format + const metrics = await metricsRegistry.metrics(); + + return c.text(metrics, 200, { + 'Content-Type': register.contentType, + }); + } catch (error) { + console.error('Failed to generate metrics:', error); + return c.json({ error: 'Failed to generate metrics' }, 500); + } +}); + +export function observeHttpRequest( + method: string, + route: string, + statusCode: number, + duration: number +) { + httpRequestDuration.labels(method, route, String(statusCode)).observe(duration); +} + +export default app; diff --git a/api/routes/secret-requests.ts b/api/routes/secret-requests.ts new file mode 100644 index 0000000..574cd15 --- /dev/null +++ b/api/routes/secret-requests.ts @@ -0,0 +1,455 @@ +import { zValidator } from '@hono/zod-validator'; +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import { Hono } from 'hono'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { isPublicUrl } from '../lib/utils'; +import { authMiddleware } from '../middlewares/auth'; +import { + createSecretRequestSchema, + processSecretRequestsQueryParams, + secretRequestIdParamSchema, + secretRequestsQuerySchema, + secretRequestTokenQuerySchema, + submitSecretRequestSchema, +} from '../validations/secret-requests'; + +// Webhook payload for secret request fulfillment +interface SecretRequestWebhookPayload { + event: 'secret_request.fulfilled'; + timestamp: string; + request: { + id: string; + title: string; + createdAt: string; + fulfilledAt: string; + }; + secret: { + id: string; + maxViews: number; + expiresAt: string; + }; +} + +// Send webhook notification when a secret request is fulfilled +async function sendSecretRequestWebhook( + webhookUrl: string, + webhookSecret: string, + payload: SecretRequestWebhookPayload +): Promise { + try { + const timestamp = Math.floor(Date.now() / 1000); + const payloadString = JSON.stringify(payload); + const signedPayload = `${timestamp}.${payloadString}`; + const signature = createHmac('sha256', webhookSecret).update(signedPayload).digest('hex'); + + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Hemmelig-Event': 'secret_request.fulfilled', + 'X-Hemmelig-Signature': `sha256=${signature}`, + 'X-Hemmelig-Timestamp': timestamp.toString(), + 'X-Hemmelig-Request-Id': payload.request.id, + 'User-Agent': 'Hemmelig-Webhook/1.0', + }; + + // Retry with exponential backoff + const maxRetries = 3; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers, + body: payloadString, + signal: AbortSignal.timeout(5000), // 5 second timeout to prevent slow-loris + redirect: 'error', // Prevent SSRF via open redirects + }); + + if (response.ok) return; + + // Don't retry for client errors (4xx) + if (response.status >= 400 && response.status < 500) { + console.error(`Secret request webhook delivery failed: ${response.status}`); + return; + } + } catch (error) { + if (attempt === maxRetries - 1) { + console.error('Secret request webhook delivery failed after retries:', error); + } + } + + // Exponential backoff: 1s, 2s, 4s + await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); + } + } catch (error) { + console.error('Error preparing secret request webhook:', error); + } +} + +// Secure token comparison - constant time for all inputs +function validateToken(provided: string, stored: string): boolean { + try { + // Pad to same length to prevent timing leaks from length comparison + const providedBuf = Buffer.alloc(32); + const storedBuf = Buffer.alloc(32); + + const providedBytes = Buffer.from(provided, 'hex'); + const storedBytes = Buffer.from(stored, 'hex'); + + // Only copy valid bytes, rest stays as zeros + if (providedBytes.length === 32) providedBytes.copy(providedBuf); + if (storedBytes.length === 32) storedBytes.copy(storedBuf); + + // Always do the comparison, even if lengths were wrong + const match = timingSafeEqual(providedBuf, storedBuf); + + // Only return true if lengths were correct AND content matches + return providedBytes.length === 32 && storedBytes.length === 32 && match; + } catch { + return false; + } +} + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + // List user's secret requests (authenticated) + .get('/', authMiddleware, zValidator('query', secretRequestsQuerySchema), async (c) => { + try { + const user = c.get('user')!; // authMiddleware guarantees user exists + + const validatedQuery = c.req.valid('query'); + const { skip, take, status } = processSecretRequestsQueryParams(validatedQuery); + + const whereClause: { userId: string; status?: string } = { userId: user.id }; + if (status && status !== 'all') { + whereClause.status = status; + } + + const [items, total] = await Promise.all([ + prisma.secretRequest.findMany({ + where: whereClause, + skip, + take, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + title: true, + description: true, + status: true, + maxViews: true, + expiresIn: true, + webhookUrl: true, + createdAt: true, + expiresAt: true, + fulfilledAt: true, + secretId: true, + }, + }), + prisma.secretRequest.count({ where: whereClause }), + ]); + + return c.json({ + data: items, + meta: { + total, + skip, + take, + page: Math.floor(skip / take) + 1, + totalPages: Math.ceil(total / take), + }, + }); + } catch (error) { + console.error('Failed to retrieve secret requests:', error); + return c.json({ error: 'Failed to retrieve secret requests' }, 500); + } + }) + // Create new secret request (authenticated) + .post('/', authMiddleware, zValidator('json', createSecretRequestSchema), async (c) => { + try { + const user = c.get('user')!; // authMiddleware guarantees user exists + + const data = c.req.valid('json'); + + if (data.webhookUrl && !(await isPublicUrl(data.webhookUrl))) { + return c.json( + { error: 'Webhook URL cannot point to private/internal addresses' }, + 400 + ); + } + + // Generate secure token (64 hex chars = 32 bytes) + const token = randomBytes(32).toString('hex'); + + // Generate webhook secret if webhook URL is provided + const webhookSecret = data.webhookUrl ? randomBytes(32).toString('hex') : null; + + const request = await prisma.secretRequest.create({ + data: { + title: data.title, + description: data.description, + maxViews: data.maxViews, + expiresIn: data.expiresIn, + allowedIp: data.allowedIp, + preventBurn: data.preventBurn, + webhookUrl: data.webhookUrl, + webhookSecret, + token, + userId: user.id, + expiresAt: new Date(Date.now() + data.validFor * 1000), + }, + }); + + // Get the base URL from the request + const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || ''; + + return c.json( + { + id: request.id, + creatorLink: `${origin}/request/${request.id}?token=${token}`, + webhookSecret, // Return once so requester can configure their webhook receiver + expiresAt: request.expiresAt, + }, + 201 + ); + } catch (error) { + console.error('Failed to create secret request:', error); + return c.json({ error: 'Failed to create secret request' }, 500); + } + }) + // Get single secret request details (authenticated, owner only) + .get('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => { + try { + const user = c.get('user')!; + const { id } = c.req.valid('param'); + + const request = await prisma.secretRequest.findUnique({ + where: { id }, + select: { + id: true, + title: true, + description: true, + status: true, + maxViews: true, + expiresIn: true, + preventBurn: true, + webhookUrl: true, + token: true, + createdAt: true, + expiresAt: true, + fulfilledAt: true, + secretId: true, + userId: true, + allowedIp: true, + }, + }); + + if (!request) { + return c.json({ error: 'Secret request not found' }, 404); + } + + if (request.userId !== user.id) { + return c.json({ error: 'Forbidden' }, 403); + } + + // Get the base URL from the request + const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || ''; + + return c.json({ + ...request, + creatorLink: `${origin}/request/${request.id}?token=${request.token}`, + }); + } catch (error) { + console.error('Failed to retrieve secret request:', error); + return c.json({ error: 'Failed to retrieve secret request' }, 500); + } + }) + // Cancel/delete secret request (authenticated, owner only) + .delete('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => { + try { + const user = c.get('user')!; + const { id } = c.req.valid('param'); + + const request = await prisma.secretRequest.findUnique({ + where: { id }, + select: { userId: true, status: true }, + }); + + if (!request) { + return c.json({ error: 'Secret request not found' }, 404); + } + + if (request.userId !== user.id) { + return c.json({ error: 'Forbidden' }, 403); + } + + // Only allow cancellation of pending requests + if (request.status !== 'pending') { + return c.json({ error: 'Can only cancel pending requests' }, 400); + } + + await prisma.secretRequest.update({ + where: { id }, + data: { status: 'cancelled' }, + }); + + return c.json({ success: true, message: 'Secret request cancelled' }); + } catch (error) { + console.error('Failed to cancel secret request:', error); + return c.json({ error: 'Failed to cancel secret request' }, 500); + } + }) + // Get request info for Creator (public, requires token) + .get( + '/:id/info', + zValidator('param', secretRequestIdParamSchema), + zValidator('query', secretRequestTokenQuerySchema), + async (c) => { + try { + const { id } = c.req.valid('param'); + const { token } = c.req.valid('query'); + + const request = await prisma.secretRequest.findUnique({ + where: { id }, + select: { + id: true, + title: true, + description: true, + status: true, + expiresAt: true, + token: true, + }, + }); + + if (!request || !validateToken(token, request.token)) { + return c.json({ error: 'Invalid or expired request' }, 404); + } + + if (request.status !== 'pending') { + return c.json({ error: 'Request already fulfilled or expired' }, 410); + } + + if (new Date() > request.expiresAt) { + // Update status to expired + await prisma.secretRequest.update({ + where: { id }, + data: { status: 'expired' }, + }); + return c.json({ error: 'Request has expired' }, 410); + } + + return c.json({ + id: request.id, + title: request.title, + description: request.description, + }); + } catch (error) { + console.error('Failed to retrieve secret request info:', error); + return c.json({ error: 'Failed to retrieve request info' }, 500); + } + } + ) + // Submit encrypted secret for request (public, requires token) + .post( + '/:id/submit', + zValidator('param', secretRequestIdParamSchema), + zValidator('query', secretRequestTokenQuerySchema), + zValidator('json', submitSecretRequestSchema), + async (c) => { + try { + const { id } = c.req.valid('param'); + const { token } = c.req.valid('query'); + const { secret, title, salt } = c.req.valid('json'); + + // Use interactive transaction to prevent race conditions + const result = await prisma.$transaction(async (tx) => { + const request = await tx.secretRequest.findUnique({ + where: { id }, + }); + + if (!request || !validateToken(token, request.token)) { + return { error: 'Invalid request', status: 404 }; + } + + if (request.status !== 'pending') { + return { error: 'Request already fulfilled', status: 410 }; + } + + if (new Date() > request.expiresAt) { + await tx.secretRequest.update({ + where: { id }, + data: { status: 'expired' }, + }); + return { error: 'Request has expired', status: 410 }; + } + + // Calculate expiration time for the secret + const secretExpiresAt = new Date(Date.now() + request.expiresIn * 1000); + + // Create secret and update request atomically + const createdSecret = await tx.secrets.create({ + data: { + secret: Buffer.from(secret), + title: title ? Buffer.from(title) : Buffer.from([]), + salt, + views: request.maxViews, + ipRange: request.allowedIp, + isBurnable: !request.preventBurn, + expiresAt: secretExpiresAt, + }, + }); + + await tx.secretRequest.update({ + where: { id }, + data: { + status: 'fulfilled', + fulfilledAt: new Date(), + secretId: createdSecret.id, + }, + }); + + return { success: true, createdSecret, request, secretExpiresAt }; + }); + + if ('error' in result) { + return c.json({ error: result.error }, result.status as 404 | 410); + } + + const { createdSecret, request, secretExpiresAt } = result; + + // Send webhook notification (async, don't block response) + if (request.webhookUrl && request.webhookSecret) { + const webhookPayload: SecretRequestWebhookPayload = { + event: 'secret_request.fulfilled', + timestamp: new Date().toISOString(), + request: { + id: request.id, + title: request.title, + createdAt: request.createdAt.toISOString(), + fulfilledAt: new Date().toISOString(), + }, + secret: { + id: createdSecret.id, + maxViews: request.maxViews, + expiresAt: secretExpiresAt.toISOString(), + }, + }; + + sendSecretRequestWebhook( + request.webhookUrl, + request.webhookSecret, + webhookPayload + ).catch(console.error); + } + + // Return secret ID (client will construct full URL with decryption key) + return c.json({ secretId: createdSecret.id }, 201); + } catch (error) { + console.error('Failed to submit secret for request:', error); + return c.json({ error: 'Failed to submit secret' }, 500); + } + } + ); + +export default app; diff --git a/api/routes/secrets.ts b/api/routes/secrets.ts new file mode 100644 index 0000000..974dd60 --- /dev/null +++ b/api/routes/secrets.ts @@ -0,0 +1,343 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { auth } from '../auth'; +import config from '../config'; +import prisma from '../lib/db'; +import { compare, hash } from '../lib/password'; +import { getInstanceSettings } from '../lib/settings'; +import { handleNotFound } from '../lib/utils'; +import { sendWebhook } from '../lib/webhook'; +import { apiKeyOrAuthMiddleware } from '../middlewares/auth'; +import { ipRestriction } from '../middlewares/ip-restriction'; +import { + createSecretsSchema, + getSecretSchema, + processSecretsQueryParams, + secretsIdParamSchema, + secretsQuerySchema, +} from '../validations/secrets'; + +interface SecretCreateData { + salt: string; + secret: Uint8Array; + title?: Uint8Array | null; + password: string | null; + expiresAt: Date; + views?: number; + isBurnable?: boolean; + ipRange?: string | null; + files?: { connect: { id: string }[] }; + userId?: string; +} + +const app = new Hono<{ + Variables: { + user: typeof auth.$Infer.Session.user | null; + }; +}>() + .get('/', apiKeyOrAuthMiddleware, zValidator('query', secretsQuerySchema), async (c) => { + try { + const user = c.get('user'); + if (!user) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const validatedQuery = c.req.valid('query'); + const options = processSecretsQueryParams(validatedQuery); + const whereClause = { userId: user.id }; + + const [items, total] = await Promise.all([ + prisma.secrets.findMany({ + where: whereClause, + skip: options.skip, + take: options.take, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + createdAt: true, + expiresAt: true, + views: true, + password: true, + ipRange: true, + isBurnable: true, + _count: { + select: { files: true }, + }, + }, + }), + prisma.secrets.count({ where: whereClause }), + ]); + + const formattedItems = items.map((item) => ({ + id: item.id, + createdAt: item.createdAt, + expiresAt: item.expiresAt, + views: item.views, + isPasswordProtected: !!item.password, + ipRange: item.ipRange, + isBurnable: item.isBurnable, + fileCount: item._count.files, + })); + + return c.json({ + data: formattedItems, + meta: { + total, + skip: options.skip, + take: options.take, + page: Math.floor(options.skip / options.take) + 1, + totalPages: Math.ceil(total / options.take), + }, + }); + } catch (error) { + console.error('Failed to retrieve secrets:', error); + return c.json( + { + error: 'Failed to retrieve secrets', + }, + 500 + ); + } + }) + .post( + '/:id', + zValidator('param', secretsIdParamSchema), + zValidator('json', getSecretSchema), + ipRestriction, + async (c) => { + try { + const { id } = c.req.valid('param'); + const data = c.req.valid('json'); + + // Atomically retrieve secret and consume view in a single transaction + const result = await prisma.$transaction(async (tx) => { + const item = await tx.secrets.findUnique({ + where: { id }, + select: { + id: true, + secret: true, + title: true, + ipRange: true, + views: true, + expiresAt: true, + createdAt: true, + isBurnable: true, + password: true, + salt: true, + files: { + select: { id: true, filename: true }, + }, + }, + }); + + if (!item) { + return { error: 'Secret not found', status: 404 as const }; + } + + // Check if secret has no views remaining (already consumed) + if (item.views !== null && item.views <= 0) { + return { error: 'Secret not found', status: 404 as const }; + } + + // Verify password if required + if (item.password) { + const isValidPassword = await compare(data.password!, item.password); + if (!isValidPassword) { + return { error: 'Invalid password', status: 401 as const }; + } + } + + // Consume the view atomically with retrieval + const newViews = item.views! - 1; + + // If burnable and last view, delete the secret after returning data + if (item.isBurnable && newViews <= 0) { + await tx.secrets.delete({ where: { id } }); + + // Send webhook for burned secret + sendWebhook('secret.burned', { + secretId: id, + hasPassword: !!item.password, + hasIpRestriction: !!item.ipRange, + }); + } else { + // Decrement views + await tx.secrets.update({ + where: { id }, + data: { views: newViews }, + }); + + // Send webhook for viewed secret + sendWebhook('secret.viewed', { + secretId: id, + hasPassword: !!item.password, + hasIpRestriction: !!item.ipRange, + viewsRemaining: newViews, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password: _password, ...itemWithoutPassword } = item; + return { + ...itemWithoutPassword, + views: newViews, + burned: item.isBurnable && newViews <= 0, + }; + }); + + if ('error' in result) { + return c.json({ error: result.error }, result.status); + } + + return c.json(result); + } catch (error) { + console.error(`Failed to retrieve item ${c.req.param('id')}:`, error); + return c.json( + { + error: 'Failed to retrieve item', + }, + 500 + ); + } + } + ) + .get('/:id/check', zValidator('param', secretsIdParamSchema), ipRestriction, async (c) => { + try { + const { id } = c.req.valid('param'); + + const item = await prisma.secrets.findUnique({ + where: { id }, + select: { + id: true, + views: true, + title: true, + password: true, + }, + }); + + if (!item) { + return c.json({ error: 'Secret not found' }, 404); + } + + // Check if secret has no views remaining (already consumed) + if (item.views !== null && item.views <= 0) { + return c.json({ error: 'Secret not found' }, 404); + } + + return c.json({ + views: item.views, + title: item.title, + isPasswordProtected: !!item.password, + }); + } catch (error) { + console.error(`Failed to check secret ${c.req.param('id')}:`, error); + return c.json( + { + error: 'Failed to check secret', + }, + 500 + ); + } + }) + .post('/', zValidator('json', createSecretsSchema), async (c) => { + try { + const user = c.get('user'); + + // Check if only registered users can create secrets + // In managed mode, use environment-based settings; otherwise use database + const settings = config.isManaged() + ? config.getManagedSettings() + : await getInstanceSettings(); + if (settings?.requireRegisteredUser && !user) { + return c.json({ error: 'Only registered users can create secrets' }, 401); + } + + const validatedData = c.req.valid('json'); + + // Enforce dynamic maxSecretSize from instance settings (in KB) + const maxSizeKB = settings?.maxSecretSize ?? 1024; + const maxSizeBytes = maxSizeKB * 1024; + if (validatedData.secret.length > maxSizeBytes) { + return c.json({ error: `Secret exceeds maximum size of ${maxSizeKB} KB` }, 413); + } + + const { expiresAt, password, fileIds, salt, title, ...rest } = validatedData; + + const data: SecretCreateData = { + ...rest, + salt, + // Title is required by the database, default to empty Uint8Array if not provided + title: title ?? new Uint8Array(0), + password: password ? await hash(password) : null, + expiresAt: new Date(Date.now() + expiresAt * 1000), + ...(fileIds && { + files: { connect: fileIds.map((id: string) => ({ id })) }, + }), + }; + + if (user) { + data.userId = user.id; + } + + const item = await prisma.secrets.create({ data }); + + return c.json({ id: item.id }, 201); + } catch (error: unknown) { + console.error('Failed to create secrets:', error); + + if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') { + const prismaError = error as { meta?: { target?: string } }; + return c.json( + { + error: 'Could not create secrets', + details: prismaError.meta?.target, + }, + 409 + ); + } + + return c.json( + { + error: 'Failed to create secret', + }, + 500 + ); + } + }) + .delete('/:id', zValidator('param', secretsIdParamSchema), async (c) => { + try { + const { id } = c.req.valid('param'); + + // Use transaction to prevent race conditions + const secret = await prisma.$transaction(async (tx) => { + // Get secret info before deleting for webhook + const secretData = await tx.secrets.findUnique({ + where: { id }, + select: { id: true, password: true, ipRange: true }, + }); + + await tx.secrets.delete({ where: { id } }); + + return secretData; + }); + + // Send webhook for manually burned secret + if (secret) { + sendWebhook('secret.burned', { + secretId: id, + hasPassword: !!secret.password, + hasIpRestriction: !!secret.ipRange, + }); + } + + return c.json({ + success: true, + message: 'Secret deleted successfully', + }); + } catch (error) { + console.error(`Failed to delete secret ${c.req.param('id')}:`, error); + return handleNotFound(error as Error & { code?: string }, c); + } + }); + +export default app; diff --git a/api/routes/setup.ts b/api/routes/setup.ts new file mode 100644 index 0000000..5ffc190 --- /dev/null +++ b/api/routes/setup.ts @@ -0,0 +1,82 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import { auth } from '../auth'; +import prisma from '../lib/db'; +import { passwordSchema } from '../validations/password'; + +const setupSchema = z.object({ + email: z.string().email(), + password: passwordSchema, + username: z.string().min(3).max(32), + name: z.string().min(1).max(100), +}); + +const app = new Hono() + // Check if setup is needed (no users exist) + .get('/status', async (c) => { + try { + const userCount = await prisma.user.count(); + return c.json({ + needsSetup: userCount === 0, + }); + } catch (error) { + console.error('Failed to check setup status:', error); + return c.json({ error: 'Failed to check setup status' }, 500); + } + }) + // Complete initial setup - create first admin user + .post('/complete', zValidator('json', setupSchema), async (c) => { + try { + // Check if any users already exist + const userCount = await prisma.user.count(); + if (userCount > 0) { + return c.json({ error: 'Setup already completed' }, 403); + } + + const { email, password, username, name } = c.req.valid('json'); + + // Create the admin user using better-auth + const result = await auth.api.signUpEmail({ + body: { + email, + password, + name, + username, + }, + }); + + if (!result.user) { + return c.json({ error: 'Failed to create admin user' }, 500); + } + + // Update user to be admin + await prisma.user.update({ + where: { id: result.user.id }, + data: { role: 'admin' }, + }); + + // Create initial instance settings if not exists + const existingSettings = await prisma.instanceSettings.findFirst(); + if (!existingSettings) { + await prisma.instanceSettings.create({ + data: {}, + }); + } + + return c.json({ + success: true, + message: 'Setup completed successfully', + }); + } catch (error) { + console.error('Failed to complete setup:', error); + return c.json( + { + error: 'Failed to complete setup', + }, + 500 + ); + } + }); + +export default app; diff --git a/api/routes/user.ts b/api/routes/user.ts new file mode 100644 index 0000000..688015b --- /dev/null +++ b/api/routes/user.ts @@ -0,0 +1,89 @@ +import { zValidator } from '@hono/zod-validator'; +import { Hono } from 'hono'; +import { z } from 'zod'; +import prisma from '../lib/db'; +import { checkAdmin } from '../middlewares/auth'; +import { updateUserSchema } from '../validations/user'; + +export const userRoute = new Hono() + .use(checkAdmin) + .get( + '/', + zValidator( + 'query', + z.object({ + page: z.coerce.number().min(1).default(1), + pageSize: z.coerce.number().min(1).max(100).default(10), + search: z.string().max(100).optional(), + }) + ), + async (c) => { + const { page, pageSize, search } = c.req.valid('query'); + const skip = (page - 1) * pageSize; + + const where = search + ? { + OR: [ + { username: { contains: search } }, + { email: { contains: search } }, + { name: { contains: search } }, + ], + } + : {}; + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + skip, + take: pageSize, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + username: true, + email: true, + role: true, + banned: true, + createdAt: true, + }, + }), + prisma.user.count({ where }), + ]); + + return c.json({ + users, + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }); + } + ) + .put( + '/:id', + zValidator('param', z.object({ id: z.string() })), + zValidator('json', updateUserSchema), + async (c) => { + const { id } = c.req.valid('param'); + const { username, email } = c.req.valid('json'); + + const data = { + ...(username && { username }), + ...(email && { email }), + }; + + const user = await prisma.user.update({ + where: { id }, + data, + select: { + id: true, + username: true, + email: true, + role: true, + banned: true, + createdAt: true, + }, + }); + + return c.json(user); + } + ); diff --git a/api/validations/account.ts b/api/validations/account.ts new file mode 100644 index 0000000..d4ec691 --- /dev/null +++ b/api/validations/account.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { passwordSchema } from './password'; + +const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, ''); + +const usernameSchema = z + .string() + .transform(sanitizeString) + .pipe( + z + .string() + .min(3, 'Username must be at least 3 characters') + .max(50, 'Username must be at most 50 characters') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Username can only contain letters, numbers, underscores, and hyphens' + ) + ); + +export const updateAccountSchema = z.object({ + username: usernameSchema, + email: z.string().email('Invalid email address'), +}); + +export const updatePasswordSchema = z + .object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: passwordSchema, + confirmPassword: z.string(), + }) + .refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); diff --git a/api/validations/instance.ts b/api/validations/instance.ts new file mode 100644 index 0000000..1ad85fd --- /dev/null +++ b/api/validations/instance.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, ''); + +const sanitizedString = (maxLength: number) => + z.string().transform(sanitizeString).pipe(z.string().max(maxLength)); + +// Max logo size: 512KB in base64 (which is ~683KB as base64 string) +const MAX_LOGO_BASE64_LENGTH = 700000; + +export const instanceSettingsSchema = z.object({ + instanceName: sanitizedString(100).optional(), + instanceDescription: sanitizedString(500).optional(), + instanceLogo: z + .string() + .max(MAX_LOGO_BASE64_LENGTH, 'Logo must be smaller than 512KB') + .refine( + (val) => { + if (!val || val === '') return true; + // Check if it's a valid base64 data URL for images + return /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp);base64,/.test(val); + }, + { message: 'Logo must be a valid image (PNG, JPEG, GIF, SVG, or WebP)' } + ) + .optional(), + allowRegistration: z.boolean().optional(), + requireEmailVerification: z.boolean().optional(), + maxSecretsPerUser: z.number().int().min(1).optional(), + defaultSecretExpiration: z.number().int().min(1).optional(), + maxSecretSize: z.number().int().min(1).optional(), + + allowPasswordProtection: z.boolean().optional(), + allowIpRestriction: z.boolean().optional(), + allowFileUploads: z.boolean().optional(), + maxPasswordAttempts: z.number().int().min(1).optional(), + sessionTimeout: z.number().int().min(1).optional(), + enableRateLimiting: z.boolean().optional(), + rateLimitRequests: z.number().int().min(1).optional(), + rateLimitWindow: z.number().int().min(1).optional(), + + // Organization features + requireInviteCode: z.boolean().optional(), + allowedEmailDomains: sanitizedString(500).optional(), + requireRegisteredUser: z.boolean().optional(), + disableEmailPasswordSignup: z.boolean().optional(), + + // Webhook notifications + webhookEnabled: z.boolean().optional(), + webhookUrl: z.string().url().optional().or(z.literal('')), + webhookSecret: sanitizedString(200).optional(), + webhookOnView: z.boolean().optional(), + webhookOnBurn: z.boolean().optional(), + + // Important message alert + importantMessage: sanitizedString(1000).optional(), + + // Prometheus metrics + metricsEnabled: z.boolean().optional(), + metricsSecret: sanitizedString(200).optional(), +}); diff --git a/api/validations/password.ts b/api/validations/password.ts new file mode 100644 index 0000000..7bd464a --- /dev/null +++ b/api/validations/password.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +/** + * Shared password strength rules. + * Used by Zod schemas and the better-auth sign-up hook. + */ +export const PASSWORD_RULES = { + minLength: 8, + patterns: [ + { regex: /[a-z]/, message: 'Password must contain at least one lowercase letter' }, + { regex: /[A-Z]/, message: 'Password must contain at least one uppercase letter' }, + { regex: /[0-9]/, message: 'Password must contain at least one number' }, + ], +} as const; + +/** + * Validates a password against strength rules. + * Returns the first error message found, or null if valid. + */ +export function validatePassword(password: string): string | null { + if (password.length < PASSWORD_RULES.minLength) { + return `Password must be at least ${PASSWORD_RULES.minLength} characters`; + } + + for (const { regex, message } of PASSWORD_RULES.patterns) { + if (!regex.test(password)) { + return message; + } + } + + return null; +} + +/** + * Zod schema for validating new password strength. + */ +export const passwordSchema = z + .string() + .min( + PASSWORD_RULES.minLength, + `Password must be at least ${PASSWORD_RULES.minLength} characters` + ) + .regex(PASSWORD_RULES.patterns[0].regex, PASSWORD_RULES.patterns[0].message) + .regex(PASSWORD_RULES.patterns[1].regex, PASSWORD_RULES.patterns[1].message) + .regex(PASSWORD_RULES.patterns[2].regex, PASSWORD_RULES.patterns[2].message); diff --git a/api/validations/secret-requests.ts b/api/validations/secret-requests.ts new file mode 100644 index 0000000..4dcb285 --- /dev/null +++ b/api/validations/secret-requests.ts @@ -0,0 +1,122 @@ +import isCidr from 'is-cidr'; +import { isIP } from 'is-ip'; +import { z } from 'zod'; +import { EXPIRATION_TIMES_SECONDS } from '../lib/constants'; + +// Valid durations for request validity (how long the creator link is active) +export const REQUEST_VALIDITY_SECONDS = [ + 2592000, // 30 days + 1209600, // 14 days + 604800, // 7 days + 259200, // 3 days + 86400, // 1 day + 43200, // 12 hours + 3600, // 1 hour +] as const; + +export const createSecretRequestSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(1000).optional(), + maxViews: z.number().int().min(1).max(9999).default(1), + expiresIn: z + .number() + .refine( + (val) => + EXPIRATION_TIMES_SECONDS.includes(val as (typeof EXPIRATION_TIMES_SECONDS)[number]), + { + message: 'Invalid expiration time for secret', + } + ), + validFor: z + .number() + .refine( + (val) => + REQUEST_VALIDITY_SECONDS.includes(val as (typeof REQUEST_VALIDITY_SECONDS)[number]), + { + message: 'Invalid validity period for request', + } + ), + + allowedIp: z + .string() + .refine((val) => isCidr(val) || isIP(val), { + message: 'Must be a valid IPv4, IPv6, or CIDR', + }) + .nullable() + .optional(), + preventBurn: z.boolean().default(false), + webhookUrl: z.string().url().optional(), +}); + +export const secretRequestIdParamSchema = z.object({ + id: z.string().uuid(), +}); + +export const secretRequestTokenQuerySchema = z.object({ + token: z.string().length(64), +}); + +// Max encrypted secret size: 1MB (1,048,576 bytes) +const MAX_SECRET_SIZE = 1024 * 1024; +// Min encrypted secret size: 28 bytes (12 IV + 16 minimum ciphertext with auth tag) +const MIN_SECRET_SIZE = 28; +// Max encrypted title size: 1KB (1,024 bytes) +const MAX_TITLE_SIZE = 1024; + +export const submitSecretRequestSchema = z.object({ + secret: z + .preprocess((arg) => { + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + const values = Object.values(arg); + return new Uint8Array(values as number[]); + } + return arg; + }, z.instanceof(Uint8Array)) + .refine((arr) => arr.length >= MIN_SECRET_SIZE, { + message: 'Secret data is too small to be valid encrypted content', + }) + .refine((arr) => arr.length <= MAX_SECRET_SIZE, { + message: `Secret exceeds maximum size of ${MAX_SECRET_SIZE} bytes`, + }), + title: z + .preprocess((arg) => { + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + const values = Object.values(arg); + return new Uint8Array(values as number[]); + } + return arg; + }, z.instanceof(Uint8Array)) + .refine((arr) => arr.length <= MAX_TITLE_SIZE, { + message: `Title exceeds maximum size of ${MAX_TITLE_SIZE} bytes`, + }) + .optional() + .nullable(), + salt: z.string().min(16).max(64), +}); + +export const secretRequestsQuerySchema = z.object({ + page: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Page must be a positive integer string', + }), + limit: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Limit must be a positive integer string', + }), + status: z.enum(['all', 'pending', 'fulfilled', 'expired', 'cancelled']).optional(), +}); + +export const processSecretRequestsQueryParams = ( + query: z.infer +) => { + const page = query.page ? parseInt(query.page, 10) : undefined; + const limit = query.limit ? parseInt(query.limit, 10) : undefined; + const take = limit && limit > 0 && limit <= 100 ? limit : 10; + const skip = page && page > 0 ? (page - 1) * take : 0; + + return { skip, take, status: query.status }; +}; diff --git a/api/validations/secrets.ts b/api/validations/secrets.ts new file mode 100644 index 0000000..f56de84 --- /dev/null +++ b/api/validations/secrets.ts @@ -0,0 +1,120 @@ +import isCidr from 'is-cidr'; +import { isIP } from 'is-ip'; +import { z } from 'zod'; +import { EXPIRATION_TIMES_SECONDS } from '../lib/constants'; + +// Hard ceiling for encrypted payloads at parse time (prevents memory exhaustion). +// Configurable via env var in KB, defaults to 1024 KB (1MB). +const MAX_ENCRYPTED_PAYLOAD_KB = parseInt( + process.env.HEMMELIG_MAX_ENCRYPTED_PAYLOAD_SIZE || '1024', + 10 +); +export const MAX_ENCRYPTED_SIZE = MAX_ENCRYPTED_PAYLOAD_KB * 1024; + +// Schema for URL parameters (expecting string from URL) +export const secretsIdParamSchema = z.object({ + id: z.string(), +}); + +// Schema for query parameters (expecting strings from URL) +export const secretsQuerySchema = z.object({ + page: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Page must be a positive integer string', + }), + limit: z + .string() + .optional() + .refine((val) => val === undefined || /^\d+$/.test(val), { + message: 'Limit must be a positive integer string', + }), +}); + +const jsonToUint8ArraySchema = z.preprocess( + (arg) => { + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + const values = Object.values(arg); + if (values.length > MAX_ENCRYPTED_SIZE) { + return arg; // Let the refine below catch the size error + } + + return new Uint8Array(values); + } + + return arg; + }, + z.instanceof(Uint8Array).refine((arr) => arr.length <= MAX_ENCRYPTED_SIZE, { + message: `Encrypted payload exceeds maximum size of ${MAX_ENCRYPTED_PAYLOAD_KB} KB`, + }) +); + +const secretSchema = { + salt: z.string(), + secret: jsonToUint8ArraySchema, + title: jsonToUint8ArraySchema.optional().nullable(), + password: z.string().optional(), + expiresAt: z + .number() + .refine( + (val) => + EXPIRATION_TIMES_SECONDS.includes(val as (typeof EXPIRATION_TIMES_SECONDS)[number]), + { + message: 'Invalid expiration time', + } + ), + views: z.number().int().min(1).max(9999).optional(), + isBurnable: z.boolean().default(true).optional(), + ipRange: z + .string() + .refine((val) => isCidr(val) || isIP(val), { + message: 'Must be a valid IPv4, IPv6, or CIDR', + }) + .nullable() + .optional(), + fileIds: z.array(z.string()).optional(), +}; + +export const createSecretsSchema = z.object(secretSchema); + +export const getSecretSchema = z.object({ + password: z.string().optional(), +}); + +const internalQueryParamsSchema = z.object({ + skip: z.number().int().min(0).optional(), + take: z.number().int().min(1).max(100).optional(), + page: z.number().int().min(1).optional(), + limit: z.number().int().min(1).max(100).optional(), +}); + +interface ProcessedSecretsQueryParams { + skip: number; + take: number; +} + +export const processSecretsQueryParams = ( + query: z.infer +): ProcessedSecretsQueryParams => { + const page = query.page ? parseInt(query.page, 10) : undefined; + const limit = query.limit ? parseInt(query.limit, 10) : undefined; + const take = limit && limit > 0 && limit <= 100 ? limit : 10; // Guaranteed number + const skip = page && page > 0 ? (page - 1) * take : 0; // Guaranteed number + + // Optional: Validate other params if needed, but we already have skip/take + const parseResult = internalQueryParamsSchema.safeParse({ + skip, + take, + page, + limit, + }); + + if (!parseResult.success) { + // Log error but return defaults for pagination + console.error('secrets query parameter processing error:', parseResult.error); + return { skip: 0, take: 10 }; + } + + return { skip, take }; +}; diff --git a/api/validations/user.ts b/api/validations/user.ts new file mode 100644 index 0000000..dd12d6b --- /dev/null +++ b/api/validations/user.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, ''); + +const usernameSchema = z + .string() + .transform(sanitizeString) + .pipe( + z + .string() + .min(3, 'Username must be at least 3 characters') + .max(50, 'Username must be at most 50 characters') + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Username can only contain letters, numbers, underscores, and hyphens' + ) + ); + +export const updateUserSchema = z.object({ + username: usernameSchema.optional(), + email: z.string().email().optional(), +}); diff --git a/banner.png b/banner.png new file mode 100644 index 0000000..9ece6e8 Binary files /dev/null and b/banner.png differ diff --git a/cli-go/.gitignore b/cli-go/.gitignore new file mode 100644 index 0000000..d52a5b1 --- /dev/null +++ b/cli-go/.gitignore @@ -0,0 +1,7 @@ +# Binary +hemmelig +hemmelig-* + +# IDE +.idea/ +.vscode/ diff --git a/cli-go/README.md b/cli-go/README.md new file mode 100644 index 0000000..6db150b --- /dev/null +++ b/cli-go/README.md @@ -0,0 +1,126 @@ +# Hemmelig CLI (Go) + +A standalone Go binary for creating encrypted, self-destructing secrets via [Hemmelig](https://hemmelig.app). + +``` + _ _ _ _ +| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _ +| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` | +| _ | __/ | | | | | | | | | | __/ | | (_| | +|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, | + |___/ +``` + +## Installation + +### Download Binary + +Download the pre-built binary for your platform from the [CLI releases](https://github.com/HemmeligOrg/Hemmelig.app/releases?q=cli-v&expanded=true). + +Replace `VERSION` below with the desired version (e.g., `1.0.0`): + +#### Linux (amd64) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-amd64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### Linux (arm64) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-arm64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### macOS (Apple Silicon) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-arm64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### macOS (Intel) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-amd64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### Windows (PowerShell) + +```powershell +$VERSION = "1.0.1" +Invoke-WebRequest -Uri "https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v$VERSION/hemmelig-windows-amd64.exe" -OutFile "hemmelig.exe" +# Move to a directory in your PATH, e.g.: +Move-Item hemmelig.exe "$env:LOCALAPPDATA\Microsoft\WindowsApps\hemmelig.exe" +``` + +#### Verify Download + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/checksums.txt -o checksums.txt +sha256sum -c checksums.txt --ignore-missing +``` + +### Build from Source + +```bash +go build -o hemmelig . +``` + +## Usage + +```bash +# Create a simple secret +hemmelig "my secret message" + +# With a title and custom expiration +hemmelig "API key: sk-1234" -t "Production API Key" -e 7d + +# Password protected +hemmelig "sensitive data" -p "mypassword" + +# Multiple views allowed +hemmelig "shared config" -v 5 + +# Pipe from stdin +cat config.json | hemmelig -t "Config file" + +# Use a self-hosted instance +hemmelig "internal secret" -u https://secrets.company.com +``` + +## Options + +| Option | Description | +| ----------------------- | ------------------------------------------------------ | +| `-t, --title ` | Set a title for the secret | +| `-p, --password <pass>` | Protect with a password | +| `-e, --expires <time>` | Expiration: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d | +| `-v, --views <number>` | Max views (1-9999, default: 1) | +| `-b, --burnable` | Burn after first view (default) | +| `--no-burnable` | Don't burn until all views used | +| `-u, --url <url>` | Base URL (default: https://hemmelig.app) | +| `-h, --help, /?` | Show help | +| `--version` | Show version | + +## Security + +- All encryption happens locally using AES-256-GCM +- Keys are derived using PBKDF2 with 600,000 iterations +- The decryption key is in the URL fragment (`#decryptionKey=...`), which is never sent to the server +- The server only stores encrypted data + +## License + +MIT diff --git a/cli-go/go.mod b/cli-go/go.mod new file mode 100644 index 0000000..91ced4b --- /dev/null +++ b/cli-go/go.mod @@ -0,0 +1,5 @@ +module github.com/HemmeligOrg/hemmelig-cli + +go 1.24.0 + +require golang.org/x/crypto v0.45.0 diff --git a/cli-go/go.sum b/cli-go/go.sum new file mode 100644 index 0000000..a13c440 --- /dev/null +++ b/cli-go/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= diff --git a/cli-go/main.go b/cli-go/main.go new file mode 100644 index 0000000..541d2d5 --- /dev/null +++ b/cli-go/main.go @@ -0,0 +1,343 @@ +package main + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + + "golang.org/x/crypto/pbkdf2" +) + +const version = "1.0.2" + +var expirationTimes = map[string]int{ + "5m": 300, + "30m": 1800, + "1h": 3600, + "4h": 14400, + "12h": 43200, + "1d": 86400, + "3d": 259200, + "7d": 604800, + "14d": 1209600, + "28d": 2419200, +} + +type Options struct { + Secret string + Title string + Password string + Expires string + Views int + Burnable bool + BaseURL string +} + +type SecretResponse struct { + ID string `json:"id"` + Error string `json:"error,omitempty"` +} + +func generateKey() string { + b := make([]byte, 24) + rand.Read(b) + encoded := base64.URLEncoding.EncodeToString(b) + if len(encoded) > 32 { + return encoded[:32] + } + return encoded +} + +func generateSalt() string { + return generateKey() +} + +func deriveKey(password, salt string) []byte { + return pbkdf2.Key([]byte(password), []byte(salt), 1300000, 32, sha256.New) +} + +func encrypt(data []byte, encryptionKey, salt string) ([]byte, error) { + key := deriveKey(encryptionKey, salt) + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + iv := make([]byte, 12) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + + ciphertext := aesGCM.Seal(nil, iv, data, nil) + + // Format: IV (12 bytes) + ciphertext (includes auth tag) + result := make([]byte, len(iv)+len(ciphertext)) + copy(result, iv) + copy(result[len(iv):], ciphertext) + + return result, nil +} + +func uint8ArrayToObject(data []byte) map[string]int { + obj := make(map[string]int) + for i, b := range data { + obj[strconv.Itoa(i)] = int(b) + } + return obj +} + +func createSecret(opts Options) (string, error) { + encryptionKey := opts.Password + if encryptionKey == "" { + encryptionKey = generateKey() + } + salt := generateSalt() + + encryptedSecret, err := encrypt([]byte(opts.Secret), encryptionKey, salt) + if err != nil { + return "", fmt.Errorf("failed to encrypt secret: %w", err) + } + + payload := map[string]interface{}{ + "secret": uint8ArrayToObject(encryptedSecret), + "salt": salt, + "expiresAt": expirationTimes[opts.Expires], + "views": opts.Views, + "isBurnable": opts.Burnable, + } + + if opts.Title != "" { + encryptedTitle, err := encrypt([]byte(opts.Title), encryptionKey, salt) + if err != nil { + return "", fmt.Errorf("failed to encrypt title: %w", err) + } + payload["title"] = uint8ArrayToObject(encryptedTitle) + } + + if opts.Password != "" { + payload["password"] = opts.Password + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + resp, err := http.Post( + opts.BaseURL+"/api/secrets", + "application/json", + bytes.NewBuffer(jsonData), + ) + if err != nil { + return "", fmt.Errorf("failed to create secret: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + var result SecretResponse + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + errMsg := result.Error + if errMsg == "" { + errMsg = "Unknown error" + } + return "", fmt.Errorf("failed to create secret: %s", errMsg) + } + + var url string + if opts.Password != "" { + url = fmt.Sprintf("%s/secret/%s", opts.BaseURL, result.ID) + } else { + url = fmt.Sprintf("%s/secret/%s#decryptionKey=%s", opts.BaseURL, result.ID, encryptionKey) + } + + return url, nil +} + +func printHelp() { + fmt.Print(` + _ _ _ _ +| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _ +| |_| |/ _ \ '_ ` + "`" + ` _ \| '_ ` + "`" + ` _ \ / _ \ | |/ _` + "`" + ` | +| _ | __/ | | | | | | | | | | __/ | | (_| | +|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, | + |___/ +Create encrypted secrets from the command line + +Usage: + hemmelig <secret> [options] + echo "secret" | hemmelig [options] + hemmelig --help + +Options: + -t, --title <title> Set a title for the secret + -p, --password <pass> Protect with a password (if not set, key is in URL) + -e, --expires <time> Expiration time (default: 1d) + Valid: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d + -v, --views <number> Max views before deletion (default: 1, max: 9999) + -b, --burnable Burn after first view (default: true) + --no-burnable Don't burn after first view + -u, --url <url> Base URL (default: https://hemmelig.app) + -h, --help, /? Show this help message + --version Show version number + +Examples: + # Create a simple secret + hemmelig "my secret message" + + # Create a secret with a title and 7-day expiration + hemmelig "my secret" -t "API Key" -e 7d + + # Create a password-protected secret + hemmelig "my secret" -p "mypassword123" + + # Create a secret with 5 views allowed + hemmelig "my secret" -v 5 + + # Pipe content from a file + cat ~/.ssh/id_rsa.pub | hemmelig -t "SSH Public Key" + + # Use a self-hosted instance + hemmelig "my secret" -u https://secrets.mycompany.com +`) +} + +func readStdin() string { + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) != 0 { + return "" + } + + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "" + } + + return strings.TrimRight(string(data), "\n\r") +} + +func parseArgs(args []string) (Options, bool, bool) { + opts := Options{ + Expires: "1d", + Views: 1, + Burnable: true, + BaseURL: "https://hemmelig.app", + } + + showHelp := false + showVersion := false + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch arg { + case "-h", "--help", "/?": + showHelp = true + case "--version": + showVersion = true + case "-t", "--title": + if i+1 < len(args) { + i++ + opts.Title = args[i] + } + case "-p", "--password": + if i+1 < len(args) { + i++ + opts.Password = args[i] + } + case "-e", "--expires": + if i+1 < len(args) { + i++ + opts.Expires = args[i] + } + case "-v", "--views": + if i+1 < len(args) { + i++ + v, err := strconv.Atoi(args[i]) + if err == nil { + opts.Views = v + } + } + case "-b", "--burnable": + opts.Burnable = true + case "--no-burnable": + opts.Burnable = false + case "-u", "--url": + if i+1 < len(args) { + i++ + opts.BaseURL = args[i] + } + default: + if !strings.HasPrefix(arg, "-") && opts.Secret == "" { + opts.Secret = arg + } + } + } + + return opts, showHelp, showVersion +} + +func main() { + opts, showHelp, showVersion := parseArgs(os.Args[1:]) + + if showVersion { + fmt.Println(version) + os.Exit(0) + } + + if showHelp { + printHelp() + os.Exit(0) + } + + if opts.Secret == "" { + opts.Secret = readStdin() + } + + if opts.Secret == "" { + fmt.Fprintln(os.Stderr, "Error: No secret provided. Use --help for usage information.") + os.Exit(1) + } + + if _, ok := expirationTimes[opts.Expires]; !ok { + fmt.Fprintf(os.Stderr, "Error: Invalid expiration time \"%s\".\n", opts.Expires) + fmt.Fprintln(os.Stderr, "Valid options: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d") + os.Exit(1) + } + + if opts.Views < 1 || opts.Views > 9999 { + fmt.Fprintln(os.Stderr, "Error: Views must be between 1 and 9999.") + os.Exit(1) + } + + url, err := createSecret(opts) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Println(url) +} diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..a0d3252 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,146 @@ +# hemmelig + +CLI and library for creating encrypted, self-destructing secrets via [Hemmelig](https://hemmelig.app). + +``` + _ _ _ _ +| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _ +| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` | +| _ | __/ | | | | | | | | | | __/ | | (_| | +|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, | + |___/ +``` + +## Features + +- **Client-side AES-256-GCM encryption** - Your secrets are encrypted before leaving your machine +- **Zero-knowledge** - The server never sees your plaintext secrets +- **Self-destructing** - Secrets auto-delete after views or expiration +- **Password protection** - Optional additional security layer +- **Works with any Hemmelig instance** - Use hemmelig.app or self-hosted + +## Installation + +```bash +npm install -g hemmelig +``` + +Or use with npx: + +```bash +npx hemmelig "my secret" +``` + +## CLI Usage + +```bash +# Create a simple secret +hemmelig "my secret message" + +# With a title and custom expiration +hemmelig "API key: sk-1234" -t "Production API Key" -e 7d + +# Password protected +hemmelig "sensitive data" -p "mypassword" + +# Multiple views allowed +hemmelig "shared config" -v 5 + +# Pipe from stdin +cat config.json | hemmelig -t "Config file" +echo "my secret" | hemmelig + +# Use a self-hosted instance +hemmelig "internal secret" -u https://secrets.company.com +``` + +### Options + +| Option | Description | +| ----------------------- | ------------------------------------------------------ | +| `-t, --title <title>` | Set a title for the secret | +| `-p, --password <pass>` | Protect with a password | +| `-e, --expires <time>` | Expiration: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d | +| `-v, --views <number>` | Max views (1-9999, default: 1) | +| `-b, --burnable` | Burn after first view (default) | +| `--no-burnable` | Don't burn until all views used | +| `-u, --url <url>` | Base URL (default: https://hemmelig.app) | +| `-h, --help` | Show help | +| `--version` | Show version | + +## Library Usage + +```typescript +import { createSecret } from 'hemmelig'; + +const result = await createSecret({ + secret: 'my secret message', + title: 'API Key', + expiresIn: '1h', + views: 1, + burnable: true, + baseUrl: 'https://hemmelig.app', // optional +}); + +console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=... +console.log(result.id); // abc123 +``` + +### API + +#### `createSecret(options: SecretOptions): Promise<CreateSecretResult>` + +Creates an encrypted secret on a Hemmelig server. + +**Options:** + +| Property | Type | Default | Description | +| ----------- | --------------- | ------------------------ | ----------------------------- | +| `secret` | `string` | required | The secret content to encrypt | +| `title` | `string` | - | Optional title | +| `password` | `string` | - | Password protection | +| `expiresIn` | `ExpirationKey` | `'1d'` | Expiration time | +| `views` | `number` | `1` | Max views (1-9999) | +| `burnable` | `boolean` | `true` | Burn on first view | +| `baseUrl` | `string` | `'https://hemmelig.app'` | Server URL | + +**Returns:** + +| Property | Type | Description | +| ----------- | -------- | ----------------------------- | +| `url` | `string` | Full URL to access the secret | +| `id` | `string` | The secret ID | +| `expiresIn` | `string` | The expiration time set | + +## CI/CD Integration + +### GitHub Actions + +```yaml +- name: Share deployment credentials + run: | + SECRET_URL=$(npx hemmelig "${{ secrets.DEPLOY_KEY }}" \ + -t "Deployment Key" \ + -e 1h) + echo "Secret URL: $SECRET_URL" +``` + +### GitLab CI + +```yaml +share-secret: + script: + - SECRET_URL=$(npx hemmelig "$DB_PASSWORD" -e 4h) + - echo "Secret URL: $SECRET_URL" +``` + +## Security + +- All encryption happens locally using AES-256-GCM +- Keys are derived using PBKDF2 with 600,000 iterations +- The decryption key is in the URL fragment (`#decryptionKey=...`), which is never sent to the server +- The server only stores encrypted data + +## License + +MIT diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..1eff4f7 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,54 @@ +{ + "name": "hemmelig", + "version": "7.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hemmelig", + "version": "7.0.0", + "license": "MIT", + "bin": { + "hemmelig": "dist/bin.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.26", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", + "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..f43e228 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,54 @@ +{ + "name": "hemmelig", + "version": "7.0.0", + "description": "CLI for creating encrypted, self-destructing secrets via Hemmelig", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "hemmelig": "dist/bin.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "hemmelig", + "secret", + "secrets", + "encryption", + "cli", + "security", + "privacy", + "self-destruct", + "one-time", + "share" + ], + "author": "Hemmelig", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/HemmeligOrg/Hemmelig.app.git", + "directory": "cli" + }, + "bugs": { + "url": "https://github.com/HemmeligOrg/Hemmelig.app/issues" + }, + "homepage": "https://hemmelig.app", + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "typescript": "^5.8.3" + } +} diff --git a/cli/src/bin.ts b/cli/src/bin.ts new file mode 100644 index 0000000..64f41ed --- /dev/null +++ b/cli/src/bin.ts @@ -0,0 +1,197 @@ +#!/usr/bin/env node + +import { createSecret, EXPIRATION_TIMES, type ExpirationKey, type SecretOptions } from './index.js'; + +const VERSION = '1.0.0'; + +/** + * Prints help message + */ +function printHelp(): void { + console.log(` + _ _ _ _ +| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _ +| |_| |/ _ \\ '_ \` _ \\| '_ \` _ \\ / _ \\ | |/ _\` | +| _ | __/ | | | | | | | | | | __/ | | (_| | +|_| |_|\\___|_| |_| |_|_| |_| |_|\\___|_|_|\\__, | + |___/ +Create encrypted secrets from the command line + +Usage: + hemmelig <secret> [options] + echo "secret" | hemmelig [options] + hemmelig --help + +Options: + -t, --title <title> Set a title for the secret + -p, --password <pass> Protect with a password (if not set, key is in URL) + -e, --expires <time> Expiration time (default: 1d) + Valid: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d + -v, --views <number> Max views before deletion (default: 1, max: 9999) + -b, --burnable Burn after first view (default: true) + --no-burnable Don't burn after first view + -u, --url <url> Base URL (default: https://hemmelig.app) + -h, --help Show this help message + --version Show version number + +Examples: + # Create a simple secret + hemmelig "my secret message" + + # Create a secret with a title and 7-day expiration + hemmelig "my secret" -t "API Key" -e 7d + + # Create a password-protected secret + hemmelig "my secret" -p "mypassword123" + + # Create a secret with 5 views allowed + hemmelig "my secret" -v 5 + + # Pipe content from a file + cat ~/.ssh/id_rsa.pub | hemmelig -t "SSH Public Key" + + # Use a self-hosted instance + hemmelig "my secret" -u https://secrets.mycompany.com +`); +} + +/** + * Parses command line arguments + */ +function parseArgs(args: string[]): SecretOptions & { help?: boolean; version?: boolean } { + const options: SecretOptions & { help?: boolean; version?: boolean } = { + secret: '', + }; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + switch (arg) { + case '-h': + case '--help': + options.help = true; + break; + case '--version': + options.version = true; + break; + case '-t': + case '--title': + options.title = args[++i]; + break; + case '-p': + case '--password': + options.password = args[++i]; + break; + case '-e': + case '--expires': + options.expiresIn = args[++i] as ExpirationKey; + break; + case '-v': + case '--views': + options.views = parseInt(args[++i], 10); + break; + case '-b': + case '--burnable': + options.burnable = true; + break; + case '--no-burnable': + options.burnable = false; + break; + case '-u': + case '--url': + options.baseUrl = args[++i]; + break; + default: + // If it doesn't start with -, it's the secret + if (!arg.startsWith('-') && !options.secret) { + options.secret = arg; + } + break; + } + i++; + } + + return options; +} + +/** + * Reads from stdin if available + */ +async function readStdin(): Promise<string> { + return new Promise((resolve) => { + // Check if stdin is a TTY (interactive terminal) + if (process.stdin.isTTY) { + resolve(''); + return; + } + + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + data += chunk; + }); + process.stdin.on('end', () => { + // Only trim trailing whitespace to preserve internal formatting + resolve(data.trimEnd()); + }); + + // Timeout after 100ms if no data + setTimeout(() => { + if (!data) { + resolve(''); + } + }, 100); + }); +} + +/** + * Main entry point + */ +async function main(): Promise<void> { + const args = process.argv.slice(2); + const options = parseArgs(args); + + if (options.version) { + console.log(VERSION); + process.exit(0); + } + + if (options.help) { + printHelp(); + process.exit(0); + } + + // Try to read from stdin if no secret provided + if (!options.secret) { + options.secret = await readStdin(); + } + + if (!options.secret) { + console.error('Error: No secret provided. Use --help for usage information.'); + process.exit(1); + } + + // Validate expiration time + if (options.expiresIn && !(options.expiresIn in EXPIRATION_TIMES)) { + console.error(`Error: Invalid expiration time "${options.expiresIn}".`); + console.error('Valid options: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d'); + process.exit(1); + } + + // Validate views + if (options.views !== undefined && (options.views < 1 || options.views > 9999)) { + console.error('Error: Views must be between 1 and 9999.'); + process.exit(1); + } + + try { + const result = await createSecret(options); + console.log(result.url); + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + process.exit(1); + } +} + +main(); diff --git a/cli/src/index.ts b/cli/src/index.ts new file mode 100644 index 0000000..0ab3332 --- /dev/null +++ b/cli/src/index.ts @@ -0,0 +1,210 @@ +import { createCipheriv, pbkdf2Sync, randomBytes } from 'node:crypto'; + +/** + * Valid expiration times in seconds + */ +export const EXPIRATION_TIMES = { + '5m': 300, + '30m': 1800, + '1h': 3600, + '4h': 14400, + '12h': 43200, + '1d': 86400, + '3d': 259200, + '7d': 604800, + '14d': 1209600, + '28d': 2419200, +} as const; + +export type ExpirationKey = keyof typeof EXPIRATION_TIMES; + +/** + * Options for creating a secret + */ +export interface SecretOptions { + /** The secret content to encrypt */ + secret: string; + /** Optional title for the secret */ + title?: string; + /** Optional password protection */ + password?: string; + /** Expiration time (default: '1d') */ + expiresIn?: ExpirationKey; + /** Maximum number of views (default: 1, max: 9999) */ + views?: number; + /** Whether to burn after first view (default: true) */ + burnable?: boolean; + /** Base URL of the Hemmelig instance (default: 'https://hemmelig.app') */ + baseUrl?: string; +} + +/** + * Result from creating a secret + */ +export interface CreateSecretResult { + /** The full URL to access the secret */ + url: string; + /** The secret ID */ + id: string; + /** The expiration time that was set */ + expiresIn: string; +} + +/** + * Generates a random 32-character string using URL-safe base64 encoding + */ +function generateKey(): string { + return randomBytes(24).toString('base64url').slice(0, 32); +} + +/** + * Generates a random 32-character salt + */ +function generateSalt(): string { + return randomBytes(24).toString('base64url').slice(0, 32); +} + +/** + * Derives a 256-bit AES key using PBKDF2-SHA256 + */ +function deriveKey(password: string, salt: string): Buffer { + return pbkdf2Sync(password, salt, 1300000, 32, 'sha256'); +} + +/** + * Encrypts data using AES-256-GCM + * Returns IV (12 bytes) + ciphertext + auth tag (16 bytes) + */ +function encrypt(data: Buffer, encryptionKey: string, salt: string): Uint8Array { + const key = deriveKey(encryptionKey, salt); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + + const encrypted = Buffer.concat([cipher.update(data), cipher.final()]); + const authTag = cipher.getAuthTag(); + + // Format: IV (12 bytes) + ciphertext + authTag (16 bytes) + const fullMessage = new Uint8Array(iv.length + encrypted.length + authTag.length); + fullMessage.set(iv, 0); + fullMessage.set(encrypted, iv.length); + fullMessage.set(authTag, iv.length + encrypted.length); + + return fullMessage; +} + +/** + * Encrypts text using AES-256-GCM + */ +function encryptText(text: string, encryptionKey: string, salt: string): Uint8Array { + return encrypt(Buffer.from(text, 'utf8'), encryptionKey, salt); +} + +/** + * Converts Uint8Array to a JSON-serializable object format + * This matches the format expected by the API's jsonToUint8ArraySchema + */ +function uint8ArrayToObject(arr: Uint8Array): Record<string, number> { + const obj: Record<string, number> = {}; + for (let i = 0; i < arr.length; i++) { + obj[i.toString()] = arr[i]; + } + return obj; +} + +/** + * Creates an encrypted secret on a Hemmelig server + * + * @example + * ```typescript + * import { createSecret } from 'hemmelig'; + * + * const result = await createSecret({ + * secret: 'my secret message', + * title: 'API Key', + * expiresIn: '1h', + * views: 1 + * }); + * + * console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=... + * ``` + */ +export async function createSecret(options: SecretOptions): Promise<CreateSecretResult> { + const { + secret, + title, + password, + expiresIn = '1d', + views = 1, + burnable = true, + baseUrl = 'https://hemmelig.app', + } = options; + + // Validate expiration time + if (!(expiresIn in EXPIRATION_TIMES)) { + throw new Error( + `Invalid expiration time "${expiresIn}". Valid options: ${Object.keys(EXPIRATION_TIMES).join(', ')}` + ); + } + + // Validate views + if (views < 1 || views > 9999) { + throw new Error('Views must be between 1 and 9999'); + } + + // Generate encryption key and salt + const encryptionKey = password || generateKey(); + const salt = generateSalt(); + + // Encrypt the secret (and title if provided) + const encryptedSecret = encryptText(secret, encryptionKey, salt); + const encryptedTitle = title ? encryptText(title, encryptionKey, salt) : null; + + // Prepare the request payload + const payload: Record<string, unknown> = { + secret: uint8ArrayToObject(encryptedSecret), + salt, + expiresAt: EXPIRATION_TIMES[expiresIn], + views, + isBurnable: burnable, + }; + + if (encryptedTitle) { + payload.title = uint8ArrayToObject(encryptedTitle); + } + + // If password is provided, send it for server-side hashing + // Otherwise, leave it empty (key will be in URL fragment) + if (password) { + payload.password = password; + } + + // Make the API request + const response = await fetch(`${baseUrl}/api/secrets`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = (await response.json().catch(() => ({ error: 'Unknown error' }))) as { + error?: string; + }; + throw new Error(`Failed to create secret: ${errorData.error || response.statusText}`); + } + + const data = (await response.json()) as { id: string }; + + // Construct the URL + // If no password was provided, include the decryption key in the URL fragment + const url = password + ? `${baseUrl}/secret/${data.id}` + : `${baseUrl}/secret/${data.id}#decryptionKey=${encryptionKey}`; + + return { + url, + id: data.id, + expiresIn, + }; +} diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..ab37c5e --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cdf30cd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + hemmelig: + image: hemmeligapp/hemmelig:v7 + container_name: hemmelig + restart: unless-stopped + volumes: + - ./database:/app/database + - ./uploads:/app/uploads + environment: + - DATABASE_URL=file:/app/database/hemmelig.db + - BETTER_AUTH_SECRET=change-this-to-a-secure-secret-min-32-chars + - BETTER_AUTH_URL=https://secrets.example.com + - NODE_ENV=production + - HEMMELIG_BASE_URL=https://secrets.example.com + ports: + - '3000:3000' + healthcheck: + test: + [ + 'CMD', + 'wget', + '--no-verbose', + '--tries=1', + '--spider', + 'http://localhost:3000/api/health/ready', + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..565a300 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,106 @@ +# API Documentation + +Hemmelig provides a REST API for programmatic access to secret sharing functionality. + +## Interactive Documentation + +The API is documented using OpenAPI 3.0 specification with an interactive Swagger UI: + +- **Swagger UI:** `/api/docs` - Interactive API explorer +- **OpenAPI Spec:** `/api/openapi.json` - Raw OpenAPI specification + +## Authentication + +### Session Authentication + +For browser-based access, authenticate through the `/auth` endpoints provided by better-auth. Session cookies are automatically managed. + +### API Key Authentication + +For programmatic access, create an API key in your account settings under the **Developer** tab. + +Use the API key as a Bearer token in the `Authorization` header: + +```bash +curl -H "Authorization: Bearer hemmelig_your_api_key_here" \ + https://your-instance.com/api/secrets +``` + +**Important:** + +- API keys are shown only once upon creation - store them securely +- Maximum 5 API keys per user +- Keys can optionally expire after 30, 90, or 365 days +- Revoke compromised keys immediately from your account settings + +For endpoints requiring admin access, the authenticated user must have the `admin` role. + +## Quick Reference + +### Public Endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------- | ------------------------------------------------- | +| GET | `/api/healthz` | Health check | +| POST | `/api/secrets` | Create a new secret | +| POST | `/api/secrets/:id` | Retrieve a secret (password in body if protected) | +| GET | `/api/secrets/:id/check` | Check if secret exists and requires password | +| POST | `/api/files` | Upload a file | +| GET | `/api/files/:id` | Download a file | +| GET | `/api/instance/settings/public` | Get public instance settings | +| GET | `/api/setup/status` | Check if initial setup is needed | +| POST | `/api/setup/complete` | Complete initial setup | + +### Authenticated Endpoints + +| Method | Endpoint | Description | +| ------ | ----------------------- | ------------------- | +| GET | `/api/secrets` | List user's secrets | +| DELETE | `/api/secrets/:id` | Delete a secret | +| GET | `/api/account` | Get account info | +| PUT | `/api/account` | Update account info | +| PUT | `/api/account/password` | Update password | +| DELETE | `/api/account` | Delete account | +| GET | `/api/api-keys` | List API keys | +| POST | `/api/api-keys` | Create API key | +| DELETE | `/api/api-keys/:id` | Delete API key | + +### Admin Endpoints + +| Method | Endpoint | Description | +| ------ | ------------------------------- | ------------------------- | +| GET | `/api/instance/settings` | Get all instance settings | +| PUT | `/api/instance/settings` | Update instance settings | +| GET | `/api/analytics` | Get secret analytics | +| GET | `/api/analytics/visitors/daily` | Get daily visitor stats | +| GET | `/api/invites` | List invite codes | +| POST | `/api/invites` | Create invite code | +| DELETE | `/api/invites/:id` | Deactivate invite code | +| PUT | `/api/user/:id` | Update user | + +## Example: Create a Secret + +```bash +curl -X POST https://your-instance.com/api/secrets \ + -H "Content-Type: application/json" \ + -d '{ + "secret": "BASE64_ENCRYPTED_CONTENT", + "salt": "ENCRYPTION_SALT", + "expiresAt": 3600, + "views": 1 + }' +``` + +Response: + +```json +{ + "id": "abc123xyz" +} +``` + +## Important Notes + +- **Client-side encryption:** All secret content should be encrypted client-side before sending to the API. The server only stores encrypted data. +- **Decryption keys:** Never send decryption keys to the server. They should be passed via URL fragments (`#key=...`) which are not transmitted to the server. +- **Rate limiting:** API requests may be rate-limited based on instance settings. diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..37e1fec --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,374 @@ +# Hemmelig CLI + +The Hemmelig CLI allows you to create encrypted secrets directly from the command line, making it ideal for automation, CI/CD pipelines, and scripting. + +``` + _ _ _ _ +| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _ +| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` | +| _ | __/ | | | | | | | | | | __/ | | (_| | +|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, | + |___/ +``` + +## Installation + +### Binary (Recommended for CI/CD) + +Download the pre-built binary for your platform from the [CLI releases](https://github.com/HemmeligOrg/Hemmelig.app/releases?q=cli-v&expanded=true). + +Replace `VERSION` below with the desired version (e.g., `1.0.0`): + +#### Linux (amd64) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-amd64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### Linux (arm64) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-arm64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### macOS (Apple Silicon) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-arm64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### macOS (Intel) + +```bash +VERSION=1.0.1 +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-amd64 -o hemmelig +chmod +x hemmelig +sudo mv hemmelig /usr/local/bin/ +``` + +#### Windows + +Download `hemmelig-windows-amd64.exe` from the [CLI releases](https://github.com/HemmeligOrg/Hemmelig.app/releases?q=cli-v&expanded=true) and add it to your PATH. + +#### Verify Download + +```bash +VERSION=1.0.1 +# Download checksums +curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/checksums.txt -o checksums.txt + +# Verify integrity +sha256sum -c checksums.txt --ignore-missing +``` + +### npm + +```bash +# Install globally +npm install -g hemmelig + +# Or use with npx (no installation required) +npx hemmelig "my secret" +``` + +## Usage + +```bash +hemmelig <secret> [options] +``` + +Or pipe content from stdin: + +```bash +echo "my secret" | hemmelig [options] +cat file.txt | hemmelig [options] +``` + +## Options + +| Option | Description | +| ----------------------- | --------------------------------------------------- | +| `-t, --title <title>` | Set a title for the secret | +| `-p, --password <pass>` | Protect with a password (if not set, key is in URL) | +| `-e, --expires <time>` | Expiration time (default: 1d) | +| `-v, --views <number>` | Max views before deletion (default: 1, max: 9999) | +| `-b, --burnable` | Burn after first view (default: true) | +| `--no-burnable` | Don't burn after first view | +| `-u, --url <url>` | Base URL (default: https://hemmelig.app) | +| `-h, --help, /?` | Show help message | + +### Expiration Times + +Valid expiration values: `5m`, `30m`, `1h`, `4h`, `12h`, `1d`, `3d`, `7d`, `14d`, `28d` + +## Examples + +### Basic Usage + +```bash +# Create a simple secret (expires in 1 day, 1 view) +hemmelig "my secret message" + +# Create a secret with a title +hemmelig "database_password=secret123" -t "Database Credentials" + +# Set custom expiration and view count +hemmelig "temporary token" -e 1h -v 3 +``` + +### Password Protection + +```bash +# Create a password-protected secret +hemmelig "sensitive data" -p "mypassword123" +``` + +When password-protected, the recipient must enter the password to decrypt the secret. The URL will not contain the decryption key. + +### Self-Hosted Instances + +```bash +# Use your own Hemmelig instance +hemmelig "internal secret" -u https://secrets.company.com +``` + +## CI/CD Integration + +The CLI is designed for automation. It outputs only the secret URL to stdout, making it easy to capture and use in scripts. + +### GitHub Actions + +Share secrets securely between workflow jobs or with external parties: + +```yaml +name: Deploy +on: [push] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Share deployment credentials + run: | + SECRET_URL=$(npx hemmelig "${{ secrets.DEPLOY_KEY }}" \ + -t "Deployment Key" \ + -e 1h \ + -v 1 \ + -u https://secrets.company.com) + echo "Secure link: $SECRET_URL" + # Send to Slack, email, etc. +``` + +### GitLab CI + +```yaml +share-credentials: + stage: deploy + script: + - | + SECRET_URL=$(npx hemmelig "$DB_PASSWORD" \ + -t "Database Password" \ + -e 4h \ + -u https://secrets.company.com) + echo "Secret URL: $SECRET_URL" +``` + +### Jenkins Pipeline + +```groovy +pipeline { + agent any + stages { + stage('Share Secret') { + steps { + script { + def secretUrl = sh( + script: ''' + npx hemmelig "${API_KEY}" \ + -t "API Key for deployment" \ + -e 1h \ + -u https://secrets.company.com + ''', + returnStdout: true + ).trim() + echo "Secret available at: ${secretUrl}" + } + } + } + } +} +``` + +## Automation Use Cases + +### Secure Credential Handoff + +When onboarding new team members or sharing credentials with contractors: + +```bash +#!/bin/bash +# generate-access.sh + +DB_CREDS="host: db.internal.com +user: app_user +password: $(openssl rand -base64 32)" + +SECRET_URL=$(echo "$DB_CREDS" | hemmelig \ + -t "Database Access - $(date +%Y-%m-%d)" \ + -e 24h \ + -v 1) + +echo "Send this link to the new team member: $SECRET_URL" +``` + +### Automated Secret Rotation + +Share rotated secrets with dependent services: + +```bash +#!/bin/bash +# rotate-and-share.sh + +NEW_PASSWORD=$(openssl rand -base64 24) + +# Update the password in your system +update_service_password "$NEW_PASSWORD" + +# Share with the dependent team +SECRET_URL=$(hemmelig "$NEW_PASSWORD" \ + -t "Rotated Service Password" \ + -e 1h \ + -v 1 \ + -u https://secrets.company.com) + +# Notify via Slack +curl -X POST "$SLACK_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d "{\"text\": \"Password rotated. New credentials: $SECRET_URL\"}" +``` + +### Sharing Build Artifacts Securely + +```bash +#!/bin/bash +# share-artifact.sh + +# Generate a signed URL or token for the artifact +ARTIFACT_TOKEN=$(generate_artifact_token) + +SECRET_URL=$(hemmelig "$ARTIFACT_TOKEN" \ + -t "Build Artifact Access Token" \ + -e 4h \ + -v 5) + +echo "Artifact access link: $SECRET_URL" +``` + +### Emergency Access Credentials + +Create break-glass credentials that self-destruct: + +```bash +#!/bin/bash +# emergency-access.sh + +EMERGENCY_CREDS=$(cat << EOF +Emergency Admin Access +====================== +URL: https://admin.company.com +Username: emergency_admin +Password: $(openssl rand -base64 32) +MFA Backup: $(generate_mfa_backup) + +This access expires in 1 hour. +EOF +) + +SECRET_URL=$(echo "$EMERGENCY_CREDS" | hemmelig \ + -t "Emergency Access Credentials" \ + -e 1h \ + -v 1 \ + -p "emergency-$(date +%s)") + +echo "Emergency access: $SECRET_URL" +echo "Password hint: emergency-[unix timestamp]" +``` + +## Programmatic Usage + +The CLI can also be used as a library in your Node.js projects: + +```typescript +import { createSecret } from 'hemmelig'; + +const result = await createSecret({ + secret: 'my secret message', + title: 'API Key', + expiresIn: '1h', + views: 1, + burnable: true, + baseUrl: 'https://hemmelig.app', // optional +}); + +console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=... +console.log(result.id); // abc123 +``` + +### API Reference + +#### `createSecret(options: SecretOptions): Promise<CreateSecretResult>` + +| Option | Type | Default | Description | +| ----------- | --------------- | ------------------------ | ----------------------------- | +| `secret` | `string` | required | The secret content to encrypt | +| `title` | `string` | - | Optional title | +| `password` | `string` | - | Password protection | +| `expiresIn` | `ExpirationKey` | `'1d'` | Expiration time | +| `views` | `number` | `1` | Max views (1-9999) | +| `burnable` | `boolean` | `true` | Burn on first view | +| `baseUrl` | `string` | `'https://hemmelig.app'` | Server URL | + +**Returns:** + +| Property | Type | Description | +| ----------- | -------- | ----------------------------- | +| `url` | `string` | Full URL to access the secret | +| `id` | `string` | The secret ID | +| `expiresIn` | `string` | The expiration time set | + +## Security Notes + +- **Client-side encryption**: All encryption happens locally before data is sent to the server +- **Zero-knowledge**: The server never sees your plaintext secrets or encryption keys +- **URL fragments**: When not using a password, the decryption key is in the URL fragment (`#decryptionKey=...`), which is never sent to the server +- **Self-destructing**: Secrets are automatically deleted after the specified views or expiration time + +## Troubleshooting + +### Secret Creation Fails + +If you're using a self-hosted instance and secret creation fails, ensure: + +1. The instance URL is correct and accessible +2. The server is running and healthy +3. CORS is configured to allow requests from the CLI origin + +### Piped Content Issues + +When piping content, the CLI preserves all internal newlines and formatting. Only trailing whitespace is trimmed. + +```bash +# This preserves the JSON formatting +cat config.json | hemmelig -t "Config" +``` diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..c305134 --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,267 @@ +# Docker Deployment + +Complete guide for deploying Hemmelig using Docker. + +## Architecture Support + +Hemmelig Docker images are built for multiple architectures: + +| Architecture | Supported | Use Case | +| ------------- | --------- | -------------------------------------------- | +| `linux/amd64` | Yes | Intel/AMD servers, most cloud providers | +| `linux/arm64` | Yes | Apple Silicon, AWS Graviton, Raspberry Pi 4+ | + +Docker will automatically pull the correct image for your platform. + +## Quick Start + +```bash +docker run -d \ + --name hemmelig \ + -p 3000:3000 \ + -v hemmelig-data:/app/database \ + -v hemmelig-uploads:/app/uploads \ + -e DATABASE_URL="file:/app/database/hemmelig.db" \ + -e BETTER_AUTH_SECRET="your-secret-key-min-32-chars" \ + -e BETTER_AUTH_URL="https://your-domain.com" \ + hemmeligapp/hemmelig:v7 +``` + +## Docker Compose + +The repository includes a ready-to-use `docker-compose.yml`: + +```bash +# Clone the repository +git clone https://github.com/HemmeligOrg/Hemmelig.app.git +cd Hemmelig.app + +# Edit environment variables +nano docker-compose.yml + +# Start the application +docker compose up -d +``` + +### Configuration + +The included `docker-compose.yml` uses SQLite: + +```yaml +services: + hemmelig: + image: hemmeligapp/hemmelig:v7 + container_name: hemmelig + restart: unless-stopped + volumes: + - ./database:/app/database + - ./uploads:/app/uploads + environment: + - DATABASE_URL=file:/app/database/hemmelig.db + - BETTER_AUTH_SECRET=change-this-to-a-secure-secret-min-32-chars + - BETTER_AUTH_URL=https://secrets.example.com + - NODE_ENV=production + - HEMMELIG_BASE_URL=https://secrets.example.com + ports: + - '3000:3000' + healthcheck: + test: + [ + 'CMD', + 'wget', + '--no-verbose', + '--tries=1', + '--spider', + 'http://localhost:3000/api/health/ready', + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s +``` + +**Important:** Before starting, update the following: + +- `BETTER_AUTH_SECRET` - Generate with `openssl rand -base64 32` +- `HEMMELIG_BASE_URL` - Your public domain URL + +## Volume Mounts + +| Container Path | Purpose | Required | +| --------------- | ----------------------- | -------- | +| `/app/database` | SQLite database storage | Yes | +| `/app/uploads` | File upload storage | Yes | + +## Environment Variables + +See [Environment Variables](./env.md) for a complete reference. + +### Required Variables + +| Variable | Description | +| -------------------- | -------------------------------------------------------- | +| `DATABASE_URL` | Database connection string | +| `BETTER_AUTH_SECRET` | Authentication secret (min 32 characters) | +| `BETTER_AUTH_URL` | Public URL of your instance (for proper cookie handling) | + +### Common Variables + +| Variable | Description | Default | +| ------------------- | ---------------------------------------------- | ------------- | +| `NODE_ENV` | Set to `production` for production deployments | `development` | +| `HEMMELIG_BASE_URL` | Public URL of your instance | - | +| `HEMMELIG_PORT` | Internal port (usually leave as default) | `3000` | + +## Troubleshooting + +### Database Permission Errors + +If you see errors like: + +``` +Error: Migration engine error: +SQLite database error +unable to open database file: /app/database/hemmelig.db +``` + +This means the container cannot write to the mounted volume. Fix by setting correct ownership on the host: + +```bash +# Find your user ID +id -u + +# Create directories and set ownership +sudo mkdir -p ./database ./uploads +sudo chown -R $(id -u):$(id -g) ./database ./uploads +``` + +Or use Docker named volumes instead of bind mounts: + +```yaml +volumes: + - hemmelig-data:/app/database + - hemmelig-uploads:/app/uploads +``` + +### File Upload Permission Errors + +If file uploads fail, ensure the uploads directory has correct permissions: + +```bash +sudo chown -R $(id -u):$(id -g) ./uploads +chmod 755 ./uploads +``` + +### Container User + +The Hemmelig container runs as user `bun` (non-root) for security. When using bind mounts, ensure the host directories are writable by UID 1000 (the default `bun` user in the container). + +## Building from Source + +To build the Docker image locally: + +```bash +git clone https://github.com/HemmeligOrg/Hemmelig.app.git +cd Hemmelig.app +docker build -t hemmelig . +``` + +### Building for ARM64 + +To build for ARM64 (e.g., for Apple Silicon or AWS Graviton): + +```bash +# Set up Docker buildx with multi-architecture support +docker buildx create --name multiarch --driver docker-container --use + +# Build for ARM64 +docker buildx build --platform linux/arm64 -t hemmelig:arm64 --load . + +# Build for both architectures +docker buildx build --platform linux/amd64,linux/arm64 -t hemmelig:latest --push . +``` + +The Dockerfile uses a cross-compilation strategy where Prisma client generation runs on the build host's native architecture to avoid QEMU emulation issues. + +## Reverse Proxy + +### Nginx + +1. Create the Nginx configuration file: + +```bash +sudo nano /etc/nginx/sites-available/hemmelig +``` + +2. Add the following configuration (HTTP only, for initial setup): + +```nginx +server { + listen 80; + server_name your_domain.com; # Replace with your domain or IP + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_cache_bypass $http_upgrade; + } +} +``` + +3. Enable the site: + +```bash +sudo ln -s /etc/nginx/sites-available/hemmelig /etc/nginx/sites-enabled/ +sudo nginx -t +sudo systemctl reload nginx +``` + +4. Install Certbot and obtain SSL certificate: + +```bash +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d your_domain.com +``` + +Certbot will automatically modify your Nginx configuration to use HTTPS. + +```` + +## Health Checks + +The container exposes a health endpoint at `/api/health/ready`. The built-in healthcheck uses `wget` to verify the application is responding and all dependencies (database, storage) are healthy. + +To manually check: + +```bash +curl http://localhost:3000/api/health/ready +# Returns: JSON with status and component health details +```` + +## Updating + +```bash +# Pull latest image +docker pull hemmeligapp/hemmelig:v7 + +# Recreate container +docker compose down +docker compose up -d +``` + +Database migrations run automatically on startup. + +## Security Notes + +1. **Always use HTTPS** in production with a reverse proxy +2. **Generate secure secrets**: `openssl rand -base64 32` +3. **Keep the image updated** for security patches +4. **Back up your data** regularly, especially the database diff --git a/docs/e2e.md b/docs/e2e.md new file mode 100644 index 0000000..cc3ea24 --- /dev/null +++ b/docs/e2e.md @@ -0,0 +1,178 @@ +# End-to-End Testing with Playwright + +Hemmelig uses [Playwright](https://playwright.dev/) for end-to-end integration testing. + +## Setup + +Playwright and browsers are installed as dev dependencies. If you need to install browser dependencies on your system: + +```bash +sudo npx playwright install-deps +``` + +### Test Database + +The e2e tests automatically use a **separate test database** (`database/hemmelig-test.db`) that is: + +1. Created fresh before each test run +2. Migrated with the latest schema +3. Seeded with a test user +4. Deleted after tests complete + +This ensures tests don't affect your development database. + +**Test User Credentials** (created automatically): + +- Email: `e2e-test@hemmelig.local` +- Username: `e2etestuser` +- Password: `E2ETestPassword123!` + +## Running Tests + +```bash +# Run all e2e tests +npm run test:e2e + +# Run tests with interactive UI +npm run test:e2e:ui + +# Run tests in debug mode +npm run test:e2e:debug + +# Run a specific test file +npx playwright test tests/e2e/secret.spec.ts + +# Run tests in headed mode (see the browser) +npx playwright test --headed +``` + +## Test Structure + +Tests are located in `tests/e2e/`: + +| File | Description | +| -------------------- | ------------------------------------------------- | +| `auth.spec.ts` | Authentication tests (setup, login, registration) | +| `home.spec.ts` | Homepage and secret form tests | +| `secret.spec.ts` | Secret creation, viewing, and deletion flows | +| `navigation.spec.ts` | Navigation and routing tests | +| `fixtures.ts` | Shared test fixtures (`authenticatedPage`) | +| `global-setup.ts` | Creates test database and user before tests | +| `global-teardown.ts` | Cleans up test database after tests | + +## Configuration + +The Playwright configuration is in `playwright.config.ts`: + +- **Test directory**: `tests/e2e/` +- **Base URL**: `http://localhost:5173` +- **Browser**: Chromium (Desktop Chrome) +- **Web server**: Automatically starts Vite with test database +- **Global setup**: Creates fresh test database and test user +- **Global teardown**: Deletes test database + +## Writing Tests + +### Basic Test Structure + +```typescript +import { expect, test } from '@playwright/test'; + +test.describe('Feature Name', () => { + test('should do something', async ({ page }) => { + await page.goto('/'); + + // Interact with elements + await page.locator('.ProseMirror').fill('My secret'); + await page.getByRole('button', { name: /create/i }).click(); + + // Assert results + await expect(page.getByText(/success/i)).toBeVisible(); + }); +}); +``` + +### Using Authenticated Page Fixture + +For tests that require authentication: + +```typescript +import { expect, test } from './fixtures'; + +test('should create a secret when logged in', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/'); + // authenticatedPage is already logged in with test user + await expect(authenticatedPage.locator('.ProseMirror')).toBeVisible(); +}); +``` + +### Common Patterns + +**Interacting with the secret editor:** + +```typescript +const editor = page.locator('.ProseMirror'); +await editor.click(); +await editor.fill('Secret content'); +``` + +**Creating and viewing a secret:** + +```typescript +// Create +await page.goto('/'); +await page.locator('.ProseMirror').fill('My secret'); +await page + .getByRole('button', { name: /create/i }) + .first() + .click(); + +// Get the URL +const urlInput = page.locator('input[readonly]').first(); +const secretUrl = await urlInput.inputValue(); + +// View +await page.goto(secretUrl); +await page.getByRole('button', { name: /unlock/i }).click(); +``` + +## CI Integration + +Tests run in CI with these settings (from `playwright.config.ts`): + +- `forbidOnly: true` - Fails if `.only` is left in tests +- `retries: 2` - Retries failed tests twice +- `workers: 1` - Single worker to prevent conflicts + +### GitHub Actions Example + +```yaml +- name: Install Playwright Browsers + run: npx playwright install --with-deps chromium + +- name: Run E2E Tests + run: npm run test:e2e +``` + +## Viewing Test Reports + +After running tests, view the HTML report: + +```bash +npx playwright show-report +``` + +## Debugging Failed Tests + +1. **Run in debug mode**: `npm run test:e2e:debug` +2. **Run with UI**: `npm run test:e2e:ui` +3. **View traces**: Failed tests generate traces in `test-results/` +4. **Screenshots**: Failed tests save screenshots automatically + +## Best Practices + +1. **Use data-testid for stable selectors** when possible +2. **Prefer user-facing selectors** like `getByRole`, `getByText`, `getByPlaceholder` +3. **Add appropriate timeouts** for async operations +4. **Keep tests independent** - each test should work in isolation +5. **Use `.first()` when multiple elements match** to avoid strict mode violations diff --git a/docs/encryption.md b/docs/encryption.md new file mode 100644 index 0000000..67639ad --- /dev/null +++ b/docs/encryption.md @@ -0,0 +1,123 @@ +# Encryption + +Hemmelig uses a **zero-knowledge architecture** where all encryption and decryption happens entirely in your browser. The server never sees your plaintext secrets or encryption keys. + +## How It Works + +1. **Secret Creation**: When you create a secret, it's encrypted in your browser before being sent to the server +2. **Key Transmission**: The decryption key is passed via URL fragment (`#decryptionKey=...`), which is never sent to the server +3. **Secret Retrieval**: When viewing a secret, the encrypted data is fetched and decrypted locally in your browser + +## Why URL Fragments? + +The decryption key is placed in the URL fragment (the part after `#`) for a critical security reason: + +**URL fragments are never transmitted to servers.** + +When you visit a URL like `https://example.com/secret/abc123#decryptionKey=xyz`: + +- The browser sends a request to `https://example.com/secret/abc123` +- The fragment (`#decryptionKey=xyz`) stays in your browser +- Server logs, proxies, load balancers, and CDNs never see the fragment +- The key exists only in the browser's address bar and JavaScript + +This is defined in [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-3.5) and is a fundamental behavior of all web browsers. + +### What This Means + +| Component | Sees the Key? | +| ----------------------------- | ------------- | +| Your browser | ✅ Yes | +| Hemmelig server | ❌ No | +| Reverse proxies (nginx, etc.) | ❌ No | +| CDNs (Cloudflare, etc.) | ❌ No | +| Server access logs | ❌ No | +| Network monitoring tools | ❌ No | + +**Note**: Be aware that browser history and bookmarks store the full URL including fragments. + +## Technical Details + +### Why This Encryption? + +Hemmelig uses **AES-256-GCM** via the **Web Crypto API** for several important reasons: + +- **Browser-native**: The Web Crypto API is built into all modern browsers. No external libraries required. +- **Hardware-accelerated**: AES is supported by dedicated instructions (AES-NI) in most modern CPUs (Intel, AMD, ARM), making encryption and decryption fast. +- **Battle-tested**: AES-256 is a NIST-approved standard. +- **Authenticated encryption**: GCM mode provides both confidentiality and integrity, detecting any tampering with the ciphertext. +- **No dependencies**: By using native browser APIs, we avoid supply chain risks from third-party cryptography libraries. + +### Algorithm + +- **Encryption**: AES-256-GCM (Galois/Counter Mode) +- **Key Derivation**: PBKDF2 with SHA-256 +- **Implementation**: Web Crypto API (browser-native) + +### Parameters + +| Parameter | Value | Description | +| ----------------- | ------------------ | -------------------------------------------------------- | +| Algorithm | AES-GCM | Authenticated encryption with associated data | +| Key Length | 256 bits | Maximum AES key size | +| IV Length | 96 bits (12 bytes) | Initialization vector, randomly generated per encryption | +| Salt Length | 32 characters | Unique per secret, stored server-side | +| PBKDF2 Iterations | 1,300,000 | Key derivation iterations | +| PBKDF2 Hash | SHA-256 | Hash function for key derivation | + +### Encryption Process + +1. **Key Generation**: A 32-character random key is generated using `nanoid`, or a user-provided password is used directly +2. **Key Derivation**: PBKDF2 derives a 256-bit AES key from the password/key and a unique salt +3. **Encryption**: AES-256-GCM encrypts the plaintext with a random 96-bit IV +4. **Output Format**: `IV (12 bytes) || Ciphertext` + +## Password Protection + +When you set a password on a secret: + +- The password is used directly as the encryption key instead of a randomly generated key +- The URL does **not** include the `#decryptionKey=...` fragment +- The recipient must enter the password manually to decrypt the secret +- This allows you to share the URL and password through separate channels for additional security + +### Decryption Process + +1. **Parse**: Extract the 12-byte IV from the beginning of the encrypted data +2. **Key Derivation**: PBKDF2 derives the same AES key using the password/key and salt +3. **Decryption**: AES-GCM decrypts and authenticates the ciphertext + +## Security Properties + +- **Confidentiality**: AES-256 provides strong encryption +- **Integrity**: GCM mode provides authenticated encryption, detecting any tampering +- **Key Strength**: PBKDF2 with 1,300,000 iterations provides resistance against brute-force attacks +- **Forward Secrecy**: Each secret uses a unique salt and random IV + +## File Encryption + +Files are encrypted using the same AES-256-GCM scheme. The file buffer is encrypted directly, and the output format is identical: `IV || Ciphertext`. + +## What the Server Stores + +- Encrypted secret (ciphertext) +- Salt (used for key derivation) +- Metadata (expiration, view count, etc.) + +## What the Server Never Sees + +- Plaintext secrets +- Encryption keys or passwords +- Decryption keys (passed via URL fragment) + +## References + +- [MDN Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) - Browser-native cryptography documentation +- [MDN AES-GCM](https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams) - AES-GCM algorithm parameters +- [MDN PBKDF2](https://developer.mozilla.org/en-US/docs/Web/API/Pbkdf2Params) - PBKDF2 key derivation parameters +- [OWASP Cryptographic Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html) - Best practices for cryptographic storage +- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) - Key derivation recommendations +- [NIST SP 800-132](https://csrc.nist.gov/pubs/sp/800/132/final) - Password-Based Key Derivation (current version) +- [NIST SP 800-132 Revision Proposal](https://csrc.nist.gov/News/2023/proposal-to-revise-nist-sp-800-132-pbkdf) - Upcoming revision with memory-hard functions +- [NIST AES Specification](https://csrc.nist.gov/publications/detail/fips/197/final) - Official AES standard (FIPS 197) +- [Crypto 101](https://www.crypto101.io/) - Free introductory course on cryptography diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 0000000..d9da9bd --- /dev/null +++ b/docs/env.md @@ -0,0 +1,186 @@ +# Environment Variables + +Complete reference for all environment variables supported by Hemmelig. + +## Required Variables + +| Variable | Description | Default | +| -------------------- | -------------------------------------------------------- | ------------------------- | +| `DATABASE_URL` | SQLite connection string | `file:./data/hemmelig.db` | +| `BETTER_AUTH_SECRET` | Secret key for authentication sessions | - | +| `BETTER_AUTH_URL` | Public URL of your instance (for proper cookie handling) | - | + +## Server Configuration + +| Variable | Description | Default | +| ------------------------- | ------------------------------------------------ | ------------- | +| `NODE_ENV` | Environment mode (`production` or `development`) | `development` | +| `HEMMELIG_PORT` | Port the server listens on | `3000` | +| `HEMMELIG_BASE_URL` | Public URL of your instance (required for OAuth) | - | +| `HEMMELIG_TRUSTED_ORIGIN` | Additional trusted origin for CORS | - | + +## General Settings + +| Variable | Description | Default | +| ------------------------------- | --------------------------------------------- | ------- | +| `HEMMELIG_INSTANCE_NAME` | Custom name for your instance | - | +| `HEMMELIG_INSTANCE_DESCRIPTION` | Custom description for your instance | - | +| `HEMMELIG_ALLOW_REGISTRATION` | Allow new user registrations (`true`/`false`) | `true` | + +## Security Settings + +| Variable | Description | Default | +| ---------------------------------------- | ------------------------------------------------------------- | ------- | +| `HEMMELIG_ALLOW_PASSWORD_PROTECTION` | Allow password-protected secrets | `true` | +| `HEMMELIG_ALLOW_IP_RESTRICTION` | Allow IP range restrictions on secrets | `true` | +| `HEMMELIG_ALLOW_FILE_UPLOADS` | Allow users to attach files to secrets | `true` | +| `HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password registration (social login only) | `false` | +| `HEMMELIG_MAX_ENCRYPTED_PAYLOAD_SIZE` | Hard ceiling for encrypted payloads in KB (parsed at startup) | `1024` | + +## Analytics + +| Variable | Description | Default | +| -------------------------------- | --------------------------------------- | -------------- | +| `HEMMELIG_ANALYTICS_ENABLED` | Enable privacy-focused analytics | `true` | +| `HEMMELIG_ANALYTICS_HMAC_SECRET` | HMAC secret for anonymizing visitor IDs | auto-generated | + +## Social Login Providers + +See [Social Login Documentation](./social-login.md) for detailed setup instructions. + +### GitHub + +| Variable | Description | +| ----------------------------- | ------------------------------ | +| `HEMMELIG_AUTH_GITHUB_ID` | GitHub OAuth App Client ID | +| `HEMMELIG_AUTH_GITHUB_SECRET` | GitHub OAuth App Client Secret | + +### Google + +| Variable | Description | +| ----------------------------- | -------------------------- | +| `HEMMELIG_AUTH_GOOGLE_ID` | Google OAuth Client ID | +| `HEMMELIG_AUTH_GOOGLE_SECRET` | Google OAuth Client Secret | + +### Microsoft (Azure AD) + +| Variable | Description | +| ----------------------------------- | --------------------------------------------------- | +| `HEMMELIG_AUTH_MICROSOFT_ID` | Microsoft Application (client) ID | +| `HEMMELIG_AUTH_MICROSOFT_SECRET` | Microsoft Client Secret | +| `HEMMELIG_AUTH_MICROSOFT_TENANT_ID` | Azure AD Tenant ID (optional, defaults to "common") | + +### Discord + +| Variable | Description | +| ------------------------------ | --------------------------------- | +| `HEMMELIG_AUTH_DISCORD_ID` | Discord Application Client ID | +| `HEMMELIG_AUTH_DISCORD_SECRET` | Discord Application Client Secret | + +### GitLab + +| Variable | Description | +| ----------------------------- | ------------------------- | +| `HEMMELIG_AUTH_GITLAB_ID` | GitLab Application ID | +| `HEMMELIG_AUTH_GITLAB_SECRET` | GitLab Application Secret | + +### Apple + +| Variable | Description | +| ---------------------------- | ------------------- | +| `HEMMELIG_AUTH_APPLE_ID` | Apple Services ID | +| `HEMMELIG_AUTH_APPLE_SECRET` | Apple Client Secret | + +### Twitter/X + +| Variable | Description | +| ------------------------------ | ------------------------------- | +| `HEMMELIG_AUTH_TWITTER_ID` | Twitter OAuth 2.0 Client ID | +| `HEMMELIG_AUTH_TWITTER_SECRET` | Twitter OAuth 2.0 Client Secret | + +### Generic OAuth + +Hemmelig supports any OAuth 2.0 / OpenID Connect provider through generic OAuth configuration. + +| Variable | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `HEMMELIG_AUTH_GENERIC_OAUTH` | JSON array of generic OAuth provider configurations. See [Social Login docs](./social-login.md) for details. | + +**Example**: + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH='[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]' +``` + +Supported generic providers include: Authentik, Authelia, Keycloak, Zitadel, Ory Hydra, and any OAuth 2.0 / OIDC-compatible identity provider. + +## Example Configuration + +### Minimal Setup + +```bash +# Required +DATABASE_URL=file:./data/hemmelig.db +BETTER_AUTH_SECRET=your-secret-key-min-32-chars-long +BETTER_AUTH_URL=https://secrets.example.com +``` + +### Production Setup + +```bash +# Required +DATABASE_URL=file:./data/hemmelig.db +BETTER_AUTH_SECRET=your-very-secure-secret-key-here +BETTER_AUTH_URL=https://secrets.example.com + +# Server +NODE_ENV=production +HEMMELIG_PORT=3000 +HEMMELIG_TRUSTED_ORIGIN=https://secrets.example.com + +# Instance +HEMMELIG_INSTANCE_NAME=Company Secrets +HEMMELIG_INSTANCE_DESCRIPTION=Secure secret sharing for our team + +# Security +HEMMELIG_ENABLE_RATE_LIMITING=true + +# Analytics +HEMMELIG_ANALYTICS_ENABLED=true +HEMMELIG_ANALYTICS_HMAC_SECRET=your-analytics-hmac-secret + +# Social Login (optional) +HEMMELIG_AUTH_GITHUB_ID=your-github-client-id +HEMMELIG_AUTH_GITHUB_SECRET=your-github-client-secret +``` + +### Docker Compose Example + +```yaml +version: '3.8' + +services: + hemmelig: + image: hemmelig/hemmelig:latest + ports: + - '3000:3000' + environment: + - DATABASE_URL=file:/data/hemmelig.db + - BETTER_AUTH_SECRET=change-this-to-a-secure-secret + - BETTER_AUTH_URL=https://secrets.example.com + - NODE_ENV=production + - HEMMELIG_PORT=3000 + - HEMMELIG_ANALYTICS_ENABLED=true + volumes: + - hemmelig_data:/data + +volumes: + hemmelig_data: +``` + +## Notes + +- Boolean values accept `true` or `false` (case-insensitive) +- All `HEMMELIG_AUTH_*` variables require both `_ID` and `_SECRET` to enable a provider +- `BETTER_AUTH_URL` is required when using social login providers +- Generate secure secrets using: `openssl rand -base64 32` diff --git a/docs/health.md b/docs/health.md new file mode 100644 index 0000000..7add4cc --- /dev/null +++ b/docs/health.md @@ -0,0 +1,95 @@ +# Health Check Endpoints + +Hemmelig provides health check endpoints for monitoring and container orchestration. + +## Endpoints + +### Liveness Probe + +``` +GET /api/health/live +``` + +Simple check confirming the process is running. Use for Kubernetes liveness probes. + +**Response:** `200 OK` + +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T10:30:00.000Z" +} +``` + +### Readiness Probe + +``` +GET /api/health/ready +``` + +Comprehensive check verifying all dependencies are operational. Use for Kubernetes readiness probes. + +**Checks performed:** + +| Check | Description | +| ------------ | ---------------------------------------- | +| **Database** | Executes `SELECT 1`, measures latency | +| **Storage** | Verifies uploads directory is read/write | +| **Memory** | Checks RSS is below 1GB threshold | + +**Response:** `200 OK` (all healthy) or `503 Service Unavailable` (one or more failed) + +```json +{ + "status": "healthy", + "timestamp": "2024-01-15T10:30:00.000Z", + "checks": { + "database": { "status": "healthy", "latency_ms": 2 }, + "storage": { "status": "healthy" }, + "memory": { + "status": "healthy", + "heap_used_mb": 128, + "heap_total_mb": 256, + "rss_mb": 312, + "rss_threshold_mb": 1024 + } + } +} +``` + +### Legacy Endpoint + +``` +GET /api/healthz +``` + +Kept for backwards compatibility. Consider using `/api/health/live` instead. + +## Kubernetes Configuration + +```yaml +livenessProbe: + httpGet: + path: /api/health/live + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: /api/health/ready + port: 3000 + initialDelaySeconds: 10 + periodSeconds: 30 +``` + +## Docker Compose + +```yaml +healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:3000/api/health/ready'] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s +``` diff --git a/docs/helm-oauth.md b/docs/helm-oauth.md new file mode 100644 index 0000000..28ac760 --- /dev/null +++ b/docs/helm-oauth.md @@ -0,0 +1,141 @@ +# Hemmelig Helm Chart - OAuth Configuration Examples + +This document demonstrates how to configure OAuth providers with the Hemmelig Helm Chart. + +## Using Default Secret Management + +The chart can automatically create secrets with your OAuth configuration. + +The example below contains all providers supported by the Helm Chart: + +```yaml +# values.yaml +config: + betterAuthSecret: "your-auth-secret-here" + betterAuthUrl: "https://secrets.example.com" + baseUrl: "https://secrets.example.com" # Required for OAuth callbacks + +oauth: + github: + enabled: true + clientId: "your-github-client-id" + clientSecret: "your-github-client-secret" + + google: + enabled: true + clientId: "your-google-client-id" + clientSecret: "your-google-client-secret" + + microsoft: + enabled: true + clientId: "your-microsoft-client-id" + clientSecret: "your-microsoft-client-secret" + tenantId: "your-tenant-id" # Optional + + discord: + enabled: true + clientId: "your-discord-client-id" + clientSecret: "your-discord-client-secret" + + gitlab: + enabled: true + clientId: "your-gitlab-client-id" + clientSecret: "your-gitlab-client-secret" + issuer: "https://gitlab.example.com" # Optional, for self-hosted GitLab + + apple: + enabled: true + clientId: "your-apple-client-id" + clientSecret: "your-apple-client-secret" + + twitter: + enabled: true + clientId: "your-twitter-client-id" + clientSecret: "your-twitter-client-secret" + + generic: '[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]' +``` + +## Using Existing Secret + +If you prefer to manage secrets yourself, reference an existing secret +and enable your desired providers: + +```yaml +# values.yaml +existingSecret: "hemmelig-secrets" + +oauth: + github: + enabled: true + google: + enabled: true + microsoft: + enabled: true + discord: + enabled: true + gitlab: + enabled: true + apple: + enabled: true + twitter: + enabled: true + generic: '[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]' +``` + +Your referenced secret should contain the relevant keys for the providers enabled: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: hemmelig-secrets +type: Opaque +stringData: + BETTER_AUTH_SECRET: "your-auth-secret" + # GitHub + HEMMELIG_AUTH_GITHUB_ID: "github-client-id" + HEMMELIG_AUTH_GITHUB_SECRET: "github-client-secret" + # Google + HEMMELIG_AUTH_GOOGLE_ID: "google-client-id" + HEMMELIG_AUTH_GOOGLE_SECRET: "google-client-secret" + # Microsoft (Azure AD) + HEMMELIG_AUTH_MICROSOFT_ID: "microsoft-client-id" + HEMMELIG_AUTH_MICROSOFT_SECRET: "microsoft-client-secret" + HEMMELIG_AUTH_MICROSOFT_TENANT_ID: "tenant-id" # Optional + # Discord + HEMMELIG_AUTH_DISCORD_ID: "discord-client-id" + HEMMELIG_AUTH_DISCORD_SECRET: "discord-client-secret" + # GitLab + HEMMELIG_AUTH_GITLAB_ID: "gitlab-client-id" + HEMMELIG_AUTH_GITLAB_SECRET: "gitlab-client-secret" + HEMMELIG_AUTH_GITLAB_ISSUER: "https://gitlab.example.com" # Optional + # Apple + HEMMELIG_AUTH_APPLE_ID: "apple-client-id" + HEMMELIG_AUTH_APPLE_SECRET: "apple-client-secret" + # Twitter/X + HEMMELIG_AUTH_TWITTER_ID: "twitter-client-id" + HEMMELIG_AUTH_TWITTER_SECRET: "twitter-client-secret" + # Generic OAuth (JSON array - supports any OAuth 2.0 / OIDC provider) + HEMMELIG_AUTH_GENERIC_OAUTH: "[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"client-secret","scopes":["openid","profile","email"]}]" +``` + +## Notes + +- All `HEMMELIG_AUTH_*` variables require both `_ID` and `_SECRET` +to enable a provider, except the "Generic" type. + +If you enable a provider and not include the required environment variables for it, +the pod will fail to start with CreateContainerConfigError, with an event +similar to the one below: + +``` +Error: couldn't find key HEMMELIG_AUTH_<missing_env> in Secret default/hemmelig +``` + +- All OAuth environment variables will be automatically injected into +the deployment, sourced either from the chart-generated secret +or your existing secret. + +- If the `existingSecret` value is provided, the `clientId`, `clientSecret`, etc. +values are ignored from the `values.yaml` diff --git a/docs/helm.md b/docs/helm.md new file mode 100644 index 0000000..11b4d24 --- /dev/null +++ b/docs/helm.md @@ -0,0 +1,205 @@ +# Helm Deployment + +Deploy Hemmelig on Kubernetes using Helm. + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.0+ +- PV provisioner support (for persistence) + +## Quick Start + +```bash +# Add the chart from local directory +cd Hemmelig.app + +# Install with default values +helm install hemmelig ./helm/hemmelig \ + --set config.betterAuthSecret="$(openssl rand -base64 32)" \ + --set config.betterAuthUrl="https://hemmelig.example.com" +``` + +## Installation + +### From Local Chart + +```bash +# Clone the repository +git clone https://github.com/HemmeligOrg/Hemmelig.app.git +cd Hemmelig.app + +# Install the chart +helm install hemmelig ./helm/hemmelig -f my-values.yaml +``` + +### Example values.yaml + +```yaml +# my-values.yaml +config: + betterAuthSecret: 'your-secret-key-min-32-chars' + betterAuthUrl: 'https://hemmelig.example.com' + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: hemmelig.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: hemmelig-tls + hosts: + - hemmelig.example.com + +persistence: + data: + enabled: true + size: 1Gi + uploads: + enabled: true + size: 10Gi + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi +``` + +## Configuration + +### Required Values + +| Parameter | Description | +| ------------------------- | ---------------------------------------------------------------------------------- | +| `config.betterAuthSecret` | Authentication secret (min 32 characters). Generate with `openssl rand -base64 32` | +| `config.betterAuthUrl` | Public URL of your instance (required for OAuth and cookie handling) | + +### Common Values + +| Parameter | Description | Default | +| ----------------------------- | ------------------------------- | ------------------- | +| `replicaCount` | Number of replicas | `1` | +| `image.repository` | Image repository | `hemmelig/hemmelig` | +| `image.tag` | Image tag | `v7` | +| `service.type` | Kubernetes service type | `ClusterIP` | +| `service.port` | Service port | `3000` | +| `ingress.enabled` | Enable ingress | `false` | +| `persistence.data.enabled` | Enable persistence for database | `true` | +| `persistence.data.size` | Database PVC size | `1Gi` | +| `persistence.uploads.enabled` | Enable persistence for uploads | `true` | +| `persistence.uploads.size` | Uploads PVC size | `5Gi` | + +### Using Existing Secrets + +Instead of setting `config.betterAuthSecret` directly, use an existing Kubernetes secret: + +```yaml +existingSecret: my-hemmelig-secret +``` + +Create the secret: + +```bash +kubectl create secret generic my-hemmelig-secret \ + --from-literal=BETTER_AUTH_SECRET="$(openssl rand -base64 32)" +``` + +### Additional Environment Variables + +```yaml +env: + - name: HEMMELIG_ANALYTICS_ENABLED + value: 'true' +``` + +## OAuth Configuration + +The Hemmelig Helm Chart supports comprehensive OAuth provider configuration. For detailed setup instructions and examples, see: + +**[OAuth Configuration with Helm](helm-oauth.md)** + +This guide covers: +- All supported OAuth providers (GitHub, Google, Microsoft, Discord, GitLab, Apple, Twitter/X) +- Generic OAuth providers (Authentik, Authelia, Keycloak, etc.) +- Default secret vs existing secret management +- Required configuration for OAuth callbacks + +## Ingress Examples + +### Nginx Ingress + +```yaml +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: '50m' + hosts: + - host: hemmelig.example.com + paths: + - path: / + pathType: Prefix +``` + +### Traefik Ingress + +```yaml +ingress: + enabled: true + className: traefik + annotations: + traefik.ingress.kubernetes.io/router.tls: 'true' + hosts: + - host: hemmelig.example.com + paths: + - path: / + pathType: Prefix +``` + +## Upgrading + +```bash +helm upgrade hemmelig ./helm/hemmelig -f my-values.yaml +``` + +## Uninstalling + +```bash +helm uninstall hemmelig +``` + +**Note:** PersistentVolumeClaims are not deleted automatically. To remove all data: + +```bash +kubectl delete pvc -l app.kubernetes.io/name=hemmelig +``` + +## Troubleshooting + +### Check Pod Status + +```bash +kubectl get pods -l app.kubernetes.io/name=hemmelig +kubectl logs -l app.kubernetes.io/name=hemmelig +``` + +### Check PVC Status + +```bash +kubectl get pvc -l app.kubernetes.io/name=hemmelig +``` + +### Port Forward for Testing + +```bash +kubectl port-forward svc/hemmelig 3000:3000 +# Visit http://localhost:3000 +``` diff --git a/docs/managed.md b/docs/managed.md new file mode 100644 index 0000000..4f1d397 --- /dev/null +++ b/docs/managed.md @@ -0,0 +1,299 @@ +# Managed Mode + +Run Hemmelig with all instance settings controlled via environment variables. Perfect for containerized deployments, GitOps workflows, and infrastructure-as-code setups where you want configuration locked down and version-controlled. + +## Overview + +When managed mode is enabled, all instance settings are read from environment variables instead of the database. The admin dashboard becomes read-only, preventing any runtime modifications. + +**Key benefits:** + +- **Immutable configuration** - Settings can't be changed through the UI +- **GitOps-friendly** - Version control your configuration +- **Reproducible deployments** - Same config across all environments +- **Security hardening** - No accidental configuration changes + +## Quick Start + +Enable managed mode by setting: + +```bash +HEMMELIG_MANAGED=true +``` + +Then configure your settings via environment variables: + +```bash +# Enable managed mode +HEMMELIG_MANAGED=true + +# Instance branding +HEMMELIG_INSTANCE_NAME="Company Secrets" +HEMMELIG_INSTANCE_DESCRIPTION="Secure secret sharing for our team" + +# Security +HEMMELIG_ALLOW_PASSWORD_PROTECTION=true +HEMMELIG_ALLOW_IP_RESTRICTION=true +HEMMELIG_ENABLE_RATE_LIMITING=true + +# Organization +HEMMELIG_REQUIRE_REGISTERED_USER=true +HEMMELIG_ALLOWED_EMAIL_DOMAINS="company.com,partner.com" +``` + +## Environment Variables + +### Core + +| Variable | Description | Default | +| ------------------ | ------------------- | ------- | +| `HEMMELIG_MANAGED` | Enable managed mode | `false` | + +### General Settings + +| Variable | Description | Default | +| ------------------------------------- | ------------------------------------- | ------- | +| `HEMMELIG_INSTANCE_NAME` | Display name for your instance | `""` | +| `HEMMELIG_INSTANCE_DESCRIPTION` | Description shown on the homepage | `""` | +| `HEMMELIG_INSTANCE_LOGO` | Base64-encoded logo image (max 512KB) | `""` | +| `HEMMELIG_ALLOW_REGISTRATION` | Allow new user signups | `true` | +| `HEMMELIG_REQUIRE_EMAIL_VERIFICATION` | Require email verification | `false` | +| `HEMMELIG_DEFAULT_SECRET_EXPIRATION` | Default expiration in hours | `72` | +| `HEMMELIG_MAX_SECRET_SIZE` | Max secret size in KB | `1024` | +| `HEMMELIG_IMPORTANT_MESSAGE` | Alert banner shown to all users | `""` | + +### Security Settings + +| Variable | Description | Default | +| ------------------------------------ | -------------------------------- | ------- | +| `HEMMELIG_ALLOW_PASSWORD_PROTECTION` | Allow password-protected secrets | `true` | +| `HEMMELIG_ALLOW_IP_RESTRICTION` | Allow IP range restrictions | `true` | +| `HEMMELIG_ALLOW_FILE_UPLOADS` | Allow users to attach files | `true` | +| `HEMMELIG_ENABLE_RATE_LIMITING` | Enable API rate limiting | `true` | +| `HEMMELIG_RATE_LIMIT_REQUESTS` | Max requests per window | `100` | +| `HEMMELIG_RATE_LIMIT_WINDOW` | Rate limit window in seconds | `60` | + +### Organization Settings + +| Variable | Description | Default | +| ---------------------------------------- | ------------------------------------------------- | ------- | +| `HEMMELIG_REQUIRE_INVITE_CODE` | Require invite code for registration | `false` | +| `HEMMELIG_ALLOWED_EMAIL_DOMAINS` | Comma-separated list of allowed domains | `""` | +| `HEMMELIG_REQUIRE_REGISTERED_USER` | Only registered users can create and read secrets | `false` | +| `HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP` | Disable email/password registration (social only) | `false` | + +### Webhook Settings + +| Variable | Description | Default | +| -------------------------- | ---------------------------------- | ------- | +| `HEMMELIG_WEBHOOK_ENABLED` | Enable webhook notifications | `false` | +| `HEMMELIG_WEBHOOK_URL` | Webhook endpoint URL | `""` | +| `HEMMELIG_WEBHOOK_SECRET` | HMAC secret for webhook signatures | `""` | +| `HEMMELIG_WEBHOOK_ON_VIEW` | Send webhook when secret is viewed | `true` | +| `HEMMELIG_WEBHOOK_ON_BURN` | Send webhook when secret is burned | `true` | + +### Metrics Settings + +| Variable | Description | Default | +| -------------------------- | ---------------------------------- | ------- | +| `HEMMELIG_METRICS_ENABLED` | Enable Prometheus metrics endpoint | `false` | +| `HEMMELIG_METRICS_SECRET` | Bearer token for `/api/metrics` | `""` | + +## Docker Compose Example + +```yaml +services: + hemmelig: + image: hemmeligapp/hemmelig:v7 + restart: unless-stopped + ports: + - '3000:3000' + volumes: + - ./database:/app/database + - ./uploads:/app/uploads + environment: + # Required + - DATABASE_URL=file:/app/database/hemmelig.db + - BETTER_AUTH_SECRET=your-secret-key-min-32-chars + - BETTER_AUTH_URL=https://secrets.example.com + - NODE_ENV=production + + # Enable managed mode + - HEMMELIG_MANAGED=true + + # General + - HEMMELIG_INSTANCE_NAME=ACME Secrets + - HEMMELIG_INSTANCE_DESCRIPTION=Internal secret sharing + - HEMMELIG_ALLOW_REGISTRATION=true + - HEMMELIG_DEFAULT_SECRET_EXPIRATION=24 + - HEMMELIG_MAX_SECRET_SIZE=2048 + + # Security + - HEMMELIG_ALLOW_PASSWORD_PROTECTION=true + - HEMMELIG_ALLOW_IP_RESTRICTION=false + - HEMMELIG_ENABLE_RATE_LIMITING=true + - HEMMELIG_RATE_LIMIT_REQUESTS=50 + - HEMMELIG_RATE_LIMIT_WINDOW=60 + + # Organization + - HEMMELIG_REQUIRE_REGISTERED_USER=true + - HEMMELIG_ALLOWED_EMAIL_DOMAINS=acme.com + + # Metrics + - HEMMELIG_METRICS_ENABLED=true + - HEMMELIG_METRICS_SECRET=prometheus-scrape-token +``` + +## Kubernetes Example + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: hemmelig-config +data: + HEMMELIG_MANAGED: 'true' + HEMMELIG_INSTANCE_NAME: 'ACME Secrets' + HEMMELIG_ALLOW_REGISTRATION: 'true' + HEMMELIG_REQUIRE_REGISTERED_USER: 'true' + HEMMELIG_ALLOWED_EMAIL_DOMAINS: 'acme.com' + HEMMELIG_ENABLE_RATE_LIMITING: 'true' + HEMMELIG_METRICS_ENABLED: 'true' +--- +apiVersion: v1 +kind: Secret +metadata: + name: hemmelig-secrets +type: Opaque +stringData: + BETTER_AUTH_SECRET: 'your-secret-key-min-32-chars' + HEMMELIG_WEBHOOK_SECRET: 'webhook-signing-secret' + HEMMELIG_METRICS_SECRET: 'prometheus-token' +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hemmelig +spec: + replicas: 1 + selector: + matchLabels: + app: hemmelig + template: + metadata: + labels: + app: hemmelig + spec: + containers: + - name: hemmelig + image: hemmeligapp/hemmelig:v7 + ports: + - containerPort: 3000 + envFrom: + - configMapRef: + name: hemmelig-config + - secretRef: + name: hemmelig-secrets + env: + - name: DATABASE_URL + value: 'file:/app/database/hemmelig.db' + - name: BETTER_AUTH_URL + value: 'https://secrets.example.com' + - name: NODE_ENV + value: 'production' +``` + +## Admin Dashboard Behavior + +When managed mode is enabled: + +1. **Settings are read-only** - All form inputs are disabled +2. **Save buttons are hidden** - No option to modify settings +3. **Banner is displayed** - Admins see a clear "Managed Mode" indicator +4. **API rejects updates** - `PUT /api/instance/settings` returns `403 Forbidden` + +Admins can still **view** all settings, making it easy to verify the configuration. + +## API Behavior + +### Check Managed Mode Status + +```bash +curl https://secrets.example.com/api/instance/managed +``` + +Response: + +```json +{ + "managed": true +} +``` + +### Settings Update (Blocked) + +When managed mode is enabled, attempting to update settings returns: + +```bash +curl -X PUT https://secrets.example.com/api/instance/settings \ + -H "Content-Type: application/json" \ + -d '{"instanceName": "New Name"}' +``` + +Response: + +```json +{ + "error": "Instance is in managed mode. Settings cannot be modified." +} +``` + +## Migration Guide + +### From Database Settings to Managed Mode + +1. **Export current settings** - Note your current configuration from the admin dashboard + +2. **Create environment configuration** - Translate settings to environment variables + +3. **Enable managed mode** - Set `HEMMELIG_MANAGED=true` + +4. **Deploy** - Restart with new configuration + +Your database settings will be ignored once managed mode is active. The database is still used for secrets, users, and other data - only instance settings come from environment variables. + +### Reverting to Database Settings + +Simply remove or set `HEMMELIG_MANAGED=false`. Settings will be read from the database again, and the admin dashboard becomes editable. + +## Best Practices + +1. **Use secrets management** - Store sensitive values like `HEMMELIG_WEBHOOK_SECRET` and `HEMMELIG_METRICS_SECRET` in a secrets manager (Vault, AWS Secrets Manager, etc.) + +2. **Version control your config** - Keep your docker-compose or Kubernetes manifests in git + +3. **Use CI/CD for changes** - Deploy configuration changes through your pipeline, not manual edits + +4. **Document your settings** - Add comments in your configuration files explaining each setting + +5. **Test in staging first** - Validate configuration changes in a non-production environment + +## Troubleshooting + +### Settings Not Applying + +- Verify `HEMMELIG_MANAGED=true` is set (case-insensitive) +- Check environment variables are being passed to the container +- Restart the application after changing environment variables + +### Dashboard Still Editable + +- Clear your browser cache +- Verify the `/api/instance/managed` endpoint returns `{"managed": true}` +- Check server logs for configuration errors + +### Rate Limiting Not Working + +- Ensure `HEMMELIG_ENABLE_RATE_LIMITING=true` +- Verify `HEMMELIG_RATE_LIMIT_REQUESTS` and `HEMMELIG_RATE_LIMIT_WINDOW` are set +- Rate limiting applies per IP address diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 0000000..ee38d66 --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,85 @@ +# Prometheus Metrics + +Hemmelig provides a Prometheus-compatible metrics endpoint for monitoring your instance. + +## Enabling Metrics + +1. Go to **Dashboard > Instance > Metrics** tab +2. Enable the **Enable Prometheus Metrics** toggle +3. Optionally, set a **Metrics Secret** for authentication +4. Save the settings + +## Endpoint + +``` +GET /api/metrics +``` + +## Authentication + +If a metrics secret is configured, you must include it as a Bearer token in the `Authorization` header: + +```bash +curl -H "Authorization: Bearer YOUR_METRICS_SECRET" https://your-instance.com/api/metrics +``` + +If no secret is configured, the endpoint is accessible without authentication (not recommended for production). + +## Available Metrics + +### Application Metrics + +| Metric | Type | Description | +| ---------------------------------------- | --------- | -------------------------------------------- | +| `hemmelig_secrets_active_count` | Gauge | Current number of active (unexpired) secrets | +| `hemmelig_users_total` | Gauge | Total number of registered users | +| `hemmelig_visitors_unique_30d` | Gauge | Unique visitors in the last 30 days | +| `hemmelig_visitors_views_30d` | Gauge | Total page views in the last 30 days | +| `hemmelig_http_request_duration_seconds` | Histogram | Duration of HTTP requests in seconds | + +### Default Node.js Metrics + +The endpoint also exposes default Node.js runtime metrics including: + +- `nodejs_heap_size_total_bytes` - Process heap size +- `nodejs_heap_size_used_bytes` - Process heap size used +- `nodejs_external_memory_bytes` - Node.js external memory +- `nodejs_eventloop_lag_seconds` - Event loop lag +- `nodejs_active_handles_total` - Number of active handles +- `nodejs_active_requests_total` - Number of active requests +- `process_cpu_user_seconds_total` - User CPU time spent +- `process_cpu_system_seconds_total` - System CPU time spent +- `process_start_time_seconds` - Process start time +- `process_resident_memory_bytes` - Resident memory size + +## Prometheus Configuration + +Add the following job to your `prometheus.yml`: + +```yaml +scrape_configs: + - job_name: 'hemmelig' + scrape_interval: 30s + static_configs: + - targets: ['your-instance.com'] + metrics_path: '/api/metrics' + scheme: https + # If using authentication: + authorization: + type: Bearer + credentials: 'YOUR_METRICS_SECRET' +``` + +## Grafana Dashboard + +You can create a Grafana dashboard to visualize these metrics. Here's an example panel query for active secrets: + +```promql +hemmelig_secrets_active_count +``` + +## Security Considerations + +- Always use a strong, randomly generated secret for the metrics endpoint in production +- Consider using network-level restrictions (firewall, VPN) to limit access to the metrics endpoint +- The metrics endpoint does not expose any sensitive data (secret contents, user data, etc.) diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..2a1671d --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,77 @@ +# SDK Generation + +> **Disclaimer:** The Hemmelig API is subject to change without notice. Generated SDKs may break with future updates. Use at your own risk. + +Hemmelig exposes an OpenAPI 3.0 specification that can be used to generate client SDKs in various programming languages. + +## OpenAPI Specification + +The OpenAPI spec is available at: + +- **Swagger UI:** `/api/docs` - Interactive API explorer +- **OpenAPI JSON:** `/api/openapi.json` - Raw specification + +## Generating an SDK + +We recommend using [OpenAPI Generator](https://openapi-generator.tech/) which supports 50+ languages. + +### Installation + +```bash +npm install -g @openapitools/openapi-generator-cli +``` + +### Generate SDK + +```bash +# TypeScript +openapi-generator-cli generate \ + -i https://your-instance.com/api/openapi.json \ + -g typescript-axios \ + -o ./hemmelig-sdk + +# Python +openapi-generator-cli generate \ + -i https://your-instance.com/api/openapi.json \ + -g python \ + -o ./hemmelig-sdk + +# Go +openapi-generator-cli generate \ + -i https://your-instance.com/api/openapi.json \ + -g go \ + -o ./hemmelig-sdk +``` + +View all available generators: + +```bash +openapi-generator-cli list +``` + +## Authentication + +The API supports two authentication methods: + +### Bearer Token (API Key) + +```typescript +const client = new HemmeligApi({ + baseURL: 'https://your-instance.com/api', + headers: { + Authorization: 'Bearer hemmelig_your_api_key_here', + }, +}); +``` + +### Session Cookie + +For browser-based applications, session cookies are automatically handled after authentication via `/auth` endpoints. + +## Important: Client-Side Encryption + +Generated SDKs handle API communication only. **You must implement client-side encryption** before sending secrets to the API. + +Hemmelig uses AES-256-GCM encryption. See the [encryption documentation](./encryption.md) for implementation details. + +The decryption key should be passed via URL fragments (`#decryptionKey=...`) which are never sent to the server. diff --git a/docs/secret-request.md b/docs/secret-request.md new file mode 100644 index 0000000..2428794 --- /dev/null +++ b/docs/secret-request.md @@ -0,0 +1,147 @@ +# Secret Requests + +Secret Requests allow you to request secrets from others securely. Instead of asking someone to create a secret and send you the link, you create a request link that they can use to submit a secret directly to you. + +## How It Works + +1. **Create a Request** - You configure the secret settings (expiration, max views, etc.) and get a unique request link +2. **Share the Link** - Send the request link to the person who has the secret +3. **They Submit** - They enter their secret, which gets encrypted in their browser. They receive a decryption key which they must send back to you. +4. **View the Secret** - You use the secret URL from your dashboard combined with the decryption key to view the secret. + +``` +┌─────────────┐ ┌─────────────┐ +│ Requester │ │ Creator │ +│ (You) │ │ (Them) │ +└──────┬──────┘ └──────┬──────┘ + │ │ + │ 1. Create request │ + │───────────────────> │ + │ │ + │ 2. Share request link │ + │ ─ ─ ─ ─ ─ ─ ─ ─ ─ > │ + │ │ + │ │ 3. Submit secret + │ │ (encrypted) + │ │ + │ 4. They send you the │ + │ decryption key │ + │< ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ + │ │ + │ 5. View secret from │ + │ dashboard with key │ + │ │ +``` + +## Creating a Request + +Navigate to **Dashboard → Secret Requests → Create New Request**. + +### Required Fields + +- **Title** - A descriptive title shown to the creator (e.g., "API credentials for Project X") + +### Optional Fields + +- **Description** - Additional context for the creator +- **Link Validity** - How long the request link remains active (1 hour to 30 days) +- **Secret Expiration** - How long the submitted secret lives (5 minutes to 28 days) +- **Max Views** - Number of times the secret can be viewed (1-9999) +- **IP Restriction** - Limit secret access to specific IP/CIDR +- **Prevent Burn** - Keep secret even after max views reached +- **Webhook URL** - Get notified when the secret is submitted + +## Webhooks + +When a secret is submitted, Hemmelig sends a POST request to your webhook URL: + +```json +{ + "event": "secret_request.fulfilled", + "timestamp": "2024-12-14T10:30:00.000Z", + "request": { + "id": "uuid", + "title": "API credentials", + "createdAt": "2024-12-14T10:00:00.000Z", + "fulfilledAt": "2024-12-14T10:30:00.000Z" + }, + "secret": { + "id": "secret-uuid", + "maxViews": 1, + "expiresAt": "2024-12-15T10:30:00.000Z" + } +} +``` + +### Webhook Security + +Webhooks are signed using HMAC-SHA256. Verify the signature to ensure authenticity: + +```javascript +const crypto = require('crypto'); + +function verifyWebhook(payload, signature, timestamp, secret) { + const signedPayload = `${timestamp}.${JSON.stringify(payload)}`; + const expectedSig = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); + + return `sha256=${expectedSig}` === signature; +} + +// Headers to check: +// X-Hemmelig-Signature: sha256=<hex> +// X-Hemmelig-Timestamp: <unix-timestamp> +``` + +**Note:** The webhook secret is shown only once when creating the request. Store it securely. + +## Security + +- **Client-side encryption** - Secrets are encrypted in the creator's browser before transmission +- **Decryption key in URL fragment** - The `#decryptionKey=...` never reaches the server +- **Single-use tokens** - Request links use 256-bit cryptographically secure tokens +- **Timing-safe validation** - Prevents timing attacks on token verification + +## API Usage + +### Create a Request + +```bash +curl -X POST https://your-instance/api/secret-requests \ + -H "Authorization: Bearer hemmelig_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Database credentials", + "description": "Need the prod DB password", + "maxViews": 1, + "expiresIn": 86400, + "validFor": 604800 + }' +``` + +### List Your Requests + +```bash +curl https://your-instance/api/secret-requests \ + -H "Authorization: Bearer hemmelig_your_api_key" +``` + +### Get Request Details + +```bash +curl https://your-instance/api/secret-requests/{id} \ + -H "Authorization: Bearer hemmelig_your_api_key" +``` + +### Cancel a Request + +```bash +curl -X DELETE https://your-instance/api/secret-requests/{id} \ + -H "Authorization: Bearer hemmelig_your_api_key" +``` + +## Limits + +- **Secret size**: 1 MB maximum +- **Title size**: 1 KB maximum +- **Request validity**: 1 hour to 30 days +- **Secret expiration**: 5 minutes to 28 days diff --git a/docs/social-login.md b/docs/social-login.md new file mode 100644 index 0000000..f89d0b2 --- /dev/null +++ b/docs/social-login.md @@ -0,0 +1,451 @@ +# Social Login Configuration + +Hemmelig supports multiple social login providers. Users can configure any combination of providers via environment variables. Only providers with valid credentials will be shown on the login and registration pages. + +## Required Configuration + +Before setting up any social provider, you must set your base URL: + +```bash +HEMMELIG_BASE_URL=https://your-domain.com +``` + +This is used to generate the correct OAuth callback URLs. + +## Supported Providers + +### Standard Providers + +| Provider | Environment Variables | +| --------- | -------------------------------------------------------------------------------------------------------------- | +| GitHub | `HEMMELIG_AUTH_GITHUB_ID`, `HEMMELIG_AUTH_GITHUB_SECRET` | +| Google | `HEMMELIG_AUTH_GOOGLE_ID`, `HEMMELIG_AUTH_GOOGLE_SECRET` | +| Microsoft | `HEMMELIG_AUTH_MICROSOFT_ID`, `HEMMELIG_AUTH_MICROSOFT_SECRET`, `HEMMELIG_AUTH_MICROSOFT_TENANT_ID` (optional) | +| Discord | `HEMMELIG_AUTH_DISCORD_ID`, `HEMMELIG_AUTH_DISCORD_SECRET` | +| GitLab | `HEMMELIG_AUTH_GITLAB_ID`, `HEMMELIG_AUTH_GITLAB_SECRET`, `HEMMELIG_AUTH_GITLAB_ISSUER` (optional) | +| Apple | `HEMMELIG_AUTH_APPLE_ID`, `HEMMELIG_AUTH_APPLE_SECRET` | +| Twitter/X | `HEMMELIG_AUTH_TWITTER_ID`, `HEMMELIG_AUTH_TWITTER_SECRET` | + +### Generic OAuth Providers + +Hemmelig now supports any OAuth 2.0 / OpenID Connect provider through the generic OAuth configuration. This allows you to integrate with identity providers like: + +- **Authentik** +- **Authelia** +- **Keycloak** +- **Zitadel** +- **Ory Hydra** +- **Auth0** (if not using the built-in provider) +- **Okta** +- Any other OAuth 2.0 / OpenID Connect compatible provider + +Use the `HEMMELIG_AUTH_GENERIC_OAUTH` environment variable with a JSON array of provider configurations. + +**Environment Variable**: `HEMMELIG_AUTH_GENERIC_OAUTH` + +#### Generic OAuth Configuration Format + +Each provider in the array must include: + +| Field | Required | Description | +| ------------------ | -------- | -------------------------------------------------------------------------- | +| `providerId` | Yes | Unique identifier for the provider (used in callback URLs) | +| `clientId` | Yes | OAuth client ID from your identity provider | +| `clientSecret` | Yes | OAuth client secret from your identity provider | +| `discoveryUrl` | No\* | OpenID Connect discovery URL (e.g., `/.well-known/openid-configuration`) | +| `authorizationUrl` | No\* | OAuth authorization endpoint (required if no `discoveryUrl`) | +| `tokenUrl` | No\* | OAuth token endpoint (required if no `discoveryUrl`) | +| `userInfoUrl` | No\* | OAuth user info endpoint (required if no `discoveryUrl`) | +| `scopes` | No | Array of OAuth scopes (default: `["openid", "profile", "email"]`) | +| `pkce` | No | Enable PKCE (Proof Key for Code Exchange) - recommended for public clients | + +\*You must provide either `discoveryUrl` OR all three of (`authorizationUrl`, `tokenUrl`, `userInfoUrl`). + +#### Example: Authentik + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}] +``` + +#### Example: Authelia + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authelia","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"your-client-secret","scopes":["openid","profile","email","groups"]}] +``` + +#### Example: Keycloak + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"keycloak","discoveryUrl":"https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}] +``` + +#### Example: Multiple Generic Providers + +You can configure multiple generic OAuth providers in the same JSON array: + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[ + { + "providerId": "authentik", + "discoveryUrl": "https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration", + "clientId": "client-id-1", + "clientSecret": "secret-1", + "scopes": ["openid", "profile", "email"] + }, + { + "providerId": "keycloak", + "discoveryUrl": "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration", + "clientId": "client-id-2", + "clientSecret": "secret-2" + } +] +``` + +#### Example: Manual URLs (without discovery) + +If your provider doesn't support OpenID Connect discovery: + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"custom","authorizationUrl":"https://oauth.example.com/authorize","tokenUrl":"https://oauth.example.com/token","userInfoUrl":"https://oauth.example.com/userinfo","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}] +``` + +## Callback URLs + +When configuring your OAuth applications, use these callback URLs: + +### Standard Providers + +| Provider | Callback URL | +| --------- | ----------------------------------------------------- | +| GitHub | `https://your-domain.com/api/auth/callback/github` | +| Google | `https://your-domain.com/api/auth/callback/google` | +| Microsoft | `https://your-domain.com/api/auth/callback/microsoft` | +| Discord | `https://your-domain.com/api/auth/callback/discord` | +| GitLab | `https://your-domain.com/api/auth/callback/gitlab` | +| Apple | `https://your-domain.com/api/auth/callback/apple` | +| Twitter/X | `https://your-domain.com/api/auth/callback/twitter` | + +### Generic OAuth Providers + +For generic OAuth providers, the callback URL format is: + +``` +https://your-domain.com/api/auth/oauth2/callback/{providerId} +``` + +Where `{providerId}` is the value you specified in the `providerId` field. + +**Examples:** + +- Authentik: `https://your-domain.com/api/auth/oauth2/callback/authentik` +- Authelia: `https://your-domain.com/api/auth/oauth2/callback/authelia` +- Keycloak: `https://your-domain.com/api/auth/oauth2/callback/keycloak` + +## Configuration + +Add the environment variables for the providers you want to enable. Both `_ID` and `_SECRET` must be set for a standard provider to be enabled. + +### Example: Docker Compose + +```yaml +services: + hemmelig: + image: hemmelig/hemmelig:latest + environment: + # Required: Base URL for OAuth callbacks + - HEMMELIG_BASE_URL=https://your-domain.com + + # Standard providers (GitHub example) + - HEMMELIG_AUTH_GITHUB_ID=your-github-client-id + - HEMMELIG_AUTH_GITHUB_SECRET=your-github-client-secret + + # Generic OAuth provider (Authentik example) + - HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}] +``` + +### Example: Environment File (.env) + +```bash +# Required: Base URL for OAuth callbacks +HEMMELIG_BASE_URL=https://your-domain.com + +# GitHub OAuth +HEMMELIG_AUTH_GITHUB_ID=your-github-client-id +HEMMELIG_AUTH_GITHUB_SECRET=your-github-client-secret + +# Google OAuth +HEMMELIG_AUTH_GOOGLE_ID=your-google-client-id +HEMMELIG_AUTH_GOOGLE_SECRET=your-google-client-secret + +# Microsoft OAuth (Azure AD) +HEMMELIG_AUTH_MICROSOFT_ID=your-microsoft-client-id +HEMMELIG_AUTH_MICROSOFT_SECRET=your-microsoft-client-secret +HEMMELIG_AUTH_MICROSOFT_TENANT_ID=your-tenant-id # Optional, defaults to "common" + +# Discord OAuth +HEMMELIG_AUTH_DISCORD_ID=your-discord-client-id +HEMMELIG_AUTH_DISCORD_SECRET=your-discord-client-secret + +# GitLab OAuth +HEMMELIG_AUTH_GITLAB_ID=your-gitlab-client-id +HEMMELIG_AUTH_GITLAB_SECRET=your-gitlab-client-secret +HEMMELIG_AUTH_GITLAB_ISSUER=https://gitlab.example.com # Optional, for self-hosted GitLab + +# Apple OAuth +HEMMELIG_AUTH_APPLE_ID=your-apple-client-id +HEMMELIG_AUTH_APPLE_SECRET=your-apple-client-secret + +# Twitter/X OAuth +HEMMELIG_AUTH_TWITTER_ID=your-twitter-client-id +HEMMELIG_AUTH_TWITTER_SECRET=your-twitter-client-secret + +# Generic OAuth (supports Authentik, Authelia, Keycloak, etc.) +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"your-client-id","clientSecret":"your-client-secret","scopes":["openid","profile","email"]}] +``` + +## Setting Up OAuth Applications + +### Standard Providers + +#### GitHub + +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +2. Click "New OAuth App" +3. Set the callback URL to: `https://your-domain.com/api/auth/callback/github` +4. Copy the Client ID and Client Secret + +#### Google + +1. Go to [Google Cloud Console](https://console.cloud.google.com/apis/credentials) +2. Create a new OAuth 2.0 Client ID +3. Set the authorized redirect URI to: `https://your-domain.com/api/auth/callback/google` +4. Copy the Client ID and Client Secret + +#### Microsoft (Azure AD) + +1. Go to [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) +2. Register a new application +3. Add a redirect URI: `https://your-domain.com/api/auth/callback/microsoft` +4. Create a client secret under "Certificates & secrets" +5. Copy the Application (client) ID and the client secret value +6. Optionally set the Tenant ID for single-tenant apps + +#### Discord + +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Create a new application +3. Go to OAuth2 settings +4. Add redirect URL: `https://your-domain.com/api/auth/callback/discord` +5. Copy the Client ID and Client Secret + +#### GitLab + +1. Go to GitLab User Settings > Applications +2. Create a new application +3. Set the redirect URI to: `https://your-domain.com/api/auth/callback/gitlab` +4. Select the `read_user` scope +5. Copy the Application ID and Secret + +**Self-hosted GitLab:** If you're using a self-hosted GitLab instance, set the `HEMMELIG_AUTH_GITLAB_ISSUER` environment variable to your GitLab instance URL (e.g., `https://gitlab.example.com`). Without this, GitLab.com is used by default. + +#### Apple + +1. Go to [Apple Developer Portal](https://developer.apple.com/account/resources/identifiers/list/serviceId) +2. Create a Services ID +3. Configure Sign in with Apple, add your domain and return URL: `https://your-domain.com/api/auth/callback/apple` +4. Create a key for Sign in with Apple +5. Use the Services ID as Client ID and generate the client secret from the key + +#### Twitter/X + +1. Go to [Twitter Developer Portal](https://developer.twitter.com/en/portal/dashboard) +2. Create a new project and app +3. Enable OAuth 2.0 +4. Set the callback URL to: `https://your-domain.com/api/auth/callback/twitter` +5. Copy the Client ID and Client Secret + +### Generic OAuth Providers + +#### Authentik + +1. Log into your Authentik instance as an admin +2. Go to **Applications** > **Providers** > **Create** +3. Select **OAuth2/OpenID Provider** +4. Configure the provider: + - **Name**: Hemmelig + - **Authorization flow**: Select your flow (e.g., default-authentication-flow) + - **Redirect URIs**: `https://your-domain.com/api/auth/oauth2/callback/authentik` + - **Client type**: Confidential + - **Scopes**: `openid`, `profile`, `email` +5. Save and copy the **Client ID** and **Client Secret** +6. Create an application and bind it to this provider +7. Find your discovery URL (usually `https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration`) + +Example environment variable: + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/application/o/hemmelig/.well-known/openid-configuration","clientId":"<client-id>","clientSecret":"<client-secret>","scopes":["openid","profile","email"]}] +``` + +#### Authelia + +1. Edit your Authelia configuration file (`configuration.yml`) +2. Add Hemmelig as a client under `identity_providers.oidc.clients`: + ```yaml + clients: + - id: hemmelig + description: Hemmelig Secret Sharing + secret: <generate-a-secure-secret> + redirect_uris: + - https://your-domain.com/api/auth/oauth2/callback/authelia + scopes: + - openid + - profile + - email + - groups + ``` +3. Restart Authelia +4. Your discovery URL will be: `https://auth.example.com/.well-known/openid-configuration` + +Example environment variable: + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authelia","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"<client-secret>","scopes":["openid","profile","email"]}] +``` + +#### Keycloak + +1. Log into your Keycloak admin console +2. Select your realm (or create a new one) +3. Go to **Clients** > **Create client** +4. Configure the client: + - **Client type**: OpenID Connect + - **Client ID**: `hemmelig` +5. On the next screen: + - **Client authentication**: ON + - **Valid redirect URIs**: `https://your-domain.com/api/auth/oauth2/callback/keycloak` + - **Web origins**: `https://your-domain.com` +6. Go to the **Credentials** tab and copy the **Client Secret** +7. Your discovery URL will be: `https://keycloak.example.com/realms/{realm-name}/.well-known/openid-configuration` + +Example environment variable: + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"keycloak","discoveryUrl":"https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration","clientId":"hemmelig","clientSecret":"<client-secret>"}] +``` + +#### Zitadel + +1. Log into your Zitadel instance +2. Go to your project (or create a new one) +3. Create a new application: + - **Type**: Web + - **Authentication method**: PKCE or Code +4. Configure: + - **Redirect URIs**: `https://your-domain.com/api/auth/oauth2/callback/zitadel` + - **Post logout redirect URIs**: `https://your-domain.com` +5. Copy the **Client ID** and **Client Secret** (if using Code flow) +6. Your discovery URL: `https://<instance>.zitadel.cloud/.well-known/openid-configuration` + +Example environment variable: + +```bash +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"zitadel","discoveryUrl":"https://instance.zitadel.cloud/.well-known/openid-configuration","clientId":"<client-id>","clientSecret":"<client-secret>","scopes":["openid","profile","email"],"pkce":true}] +``` + +## How It Works + +1. On server startup, the application reads all `HEMMELIG_AUTH_*` environment variables +2. Only providers with both `_ID` and `_SECRET` set are enabled +3. The frontend fetches the list of enabled providers from `/api/config/social-providers` +4. Login and registration pages dynamically show buttons only for enabled providers +5. Each provider button uses the correct branded icon and colors +6. The callback URL is built using `HEMMELIG_BASE_URL` + `/api/auth/callback/{provider}` + +## All Environment Variables + +```bash +# Required for OAuth +HEMMELIG_BASE_URL=https://your-domain.com + +# GitHub +HEMMELIG_AUTH_GITHUB_ID= +HEMMELIG_AUTH_GITHUB_SECRET= + +# Google +HEMMELIG_AUTH_GOOGLE_ID= +HEMMELIG_AUTH_GOOGLE_SECRET= + +# Microsoft (Azure AD) +HEMMELIG_AUTH_MICROSOFT_ID= +HEMMELIG_AUTH_MICROSOFT_SECRET= +HEMMELIG_AUTH_MICROSOFT_TENANT_ID= # Optional + +# Discord +HEMMELIG_AUTH_DISCORD_ID= +HEMMELIG_AUTH_DISCORD_SECRET= + +# GitLab +HEMMELIG_AUTH_GITLAB_ID= +HEMMELIG_AUTH_GITLAB_SECRET= +HEMMELIG_AUTH_GITLAB_ISSUER= # Optional, for self-hosted GitLab (e.g., https://gitlab.example.com) + +# Apple +HEMMELIG_AUTH_APPLE_ID= +HEMMELIG_AUTH_APPLE_SECRET= + +# Twitter/X +HEMMELIG_AUTH_TWITTER_ID= +HEMMELIG_AUTH_TWITTER_SECRET= + +# Generic OAuth (JSON array - supports any OAuth 2.0 / OIDC provider) +HEMMELIG_AUTH_GENERIC_OAUTH=[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"client-secret","scopes":["openid","profile","email"]}] +``` + +## Troubleshooting + +### Provider not showing up + +**Standard providers:** + +- Ensure both `_ID` and `_SECRET` environment variables are set +- Restart the server after adding environment variables +- Check server logs for any configuration errors + +**Generic OAuth providers:** + +- Verify the JSON in `HEMMELIG_AUTH_GENERIC_OAUTH` is valid +- Check that each provider has `providerId`, `clientId`, and `clientSecret` +- Verify you have either `discoveryUrl` OR all three URLs (`authorizationUrl`, `tokenUrl`, `userInfoUrl`) +- Check server logs for parsing errors + +### OAuth callback errors + +**Standard providers:** + +- Verify the callback URL in your OAuth app settings matches exactly +- Format: `https://your-domain.com/api/auth/callback/{provider}` + +**Generic OAuth providers:** + +- Callback URL format: `https://your-domain.com/api/auth/oauth2/callback/{providerId}` +- Ensure the `providerId` in your config matches the one in your identity provider settings + +**Common issues:** + +- Ensure `HEMMELIG_BASE_URL` is set correctly (no trailing slash) +- Ensure your domain is using HTTPS in production +- Check that the client ID and secret are correct (no extra spaces) + +### "Access Denied" errors + +- Verify the OAuth app has the correct permissions/scopes +- For Microsoft, ensure the app is configured for the correct account types +- For Apple, ensure the Services ID is correctly configured +- For generic OAuth: Check that the requested scopes are allowed by your provider + +### Discovery URL errors (Generic OAuth) + +- Verify the discovery URL is accessible: `curl https://your-auth-provider/.well-known/openid-configuration` +- Ensure your Hemmelig instance can reach the discovery URL (check firewall rules) +- Try using manual URLs (`authorizationUrl`, `tokenUrl`, `userInfoUrl`) instead if discovery is not supported diff --git a/docs/upgrade.md b/docs/upgrade.md new file mode 100644 index 0000000..939f258 --- /dev/null +++ b/docs/upgrade.md @@ -0,0 +1,33 @@ +# Upgrading from v6 to v7 + +## ⚠️ Breaking Changes - Fresh Start Required + +**v7 is a complete rewrite and is NOT backwards compatible with v6.** Due to fundamental changes in the encryption and password hashing algorithms, migration of existing data is not possible. + +### What Changed + +| Component | v6 | v7 | +| -------------------- | ------------------- | ----------------------- | +| Encryption Algorithm | Different algorithm | AES-256-GCM with PBKDF2 | +| Password Hashing | Different algorithm | Updated secure hashing | +| Database Schema | Previous schema | New schema structure | + +### Why Migration Is Not Possible + +1. **Encryption Algorithm Change:** Secrets encrypted with the v6 algorithm cannot be decrypted with v7's implementation. Since the server never stores decryption keys (zero-knowledge architecture), there is no way to re-encrypt existing secrets. + +2. **Password Algorithm Change:** User passwords are hashed differently in v7. Existing password hashes from v6 cannot be verified or converted. + +### Upgrade Steps + +1. **Backup v6 data** (for reference only - it cannot be migrated) +2. **Stop your v6 instance** +3. **Deploy v7 with a fresh database** - See [Docker Guide](docker.md) for deployment instructions +4. **Re-create user accounts** +5. **Inform users** that existing secrets are no longer accessible + +### Important Notes + +- No migration scripts are provided or planned +- Users must register new accounts in v7 +- Consider running v6 in read-only mode temporarily to allow users to retrieve unexpired secrets before shutdown diff --git a/docs/webhook.md b/docs/webhook.md new file mode 100644 index 0000000..c28c70a --- /dev/null +++ b/docs/webhook.md @@ -0,0 +1,230 @@ +# Webhook Notifications + +Hemmelig can send HTTP POST requests to your webhook URL when secrets are viewed or burned. This allows you to integrate with external services like Slack, Discord, monitoring systems, or custom applications. + +## Configuration + +Configure webhooks in the admin dashboard under **Instance Settings → Webhooks**. + +| Setting | Description | +| ------------------- | ------------------------------------------ | +| **Enable Webhooks** | Turn webhook notifications on/off | +| **Webhook URL** | The endpoint where payloads are sent | +| **Webhook Secret** | Secret key for HMAC-SHA256 payload signing | +| **Secret Viewed** | Send webhook when a secret is viewed | +| **Secret Burned** | Send webhook when a secret is deleted | + +## Webhook Payload + +Webhooks are sent as HTTP POST requests with a JSON body: + +### Secret Events + +```json +{ + "event": "secret.viewed", + "timestamp": "2024-12-04T10:30:00.000Z", + "data": { + "secretId": "abc123-def456", + "hasPassword": true, + "hasIpRestriction": false, + "viewsRemaining": 2 + } +} +``` + +### API Key Events + +```json +{ + "event": "apikey.created", + "timestamp": "2024-12-04T10:30:00.000Z", + "data": { + "apiKeyId": "key-uuid-here", + "name": "My Integration", + "expiresAt": "2025-12-04T10:30:00.000Z", + "userId": "user-uuid-here" + } +} +``` + +### Event Types + +| Event | Description | +| ---------------- | -------------------------------------------------- | +| `secret.viewed` | A secret was successfully viewed | +| `secret.burned` | A secret was deleted (manually or after last view) | +| `apikey.created` | A new API key was created | + +### Headers + +| Header | Description | +| ---------------------- | ------------------------------------------------------------------ | +| `Content-Type` | `application/json` | +| `X-Hemmelig-Event` | Event type (`secret.viewed`, `secret.burned`, or `apikey.created`) | +| `X-Hemmelig-Signature` | HMAC-SHA256 signature (if secret configured) | + +## Verifying Webhook Signatures + +If you configure a webhook secret, Hemmelig signs each payload using HMAC-SHA256. The signature is sent in the `X-Hemmelig-Signature` header as `sha256=<hex>`. + +**Always verify signatures** to ensure webhooks are authentic and haven't been tampered with. + +### Node.js Example + +```javascript +const crypto = require('crypto'); + +function verifyWebhook(payload, signature, secret) { + const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); + + return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); +} + +// Express.js middleware +app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => { + const signature = req.headers['x-hemmelig-signature']; + const payload = req.body.toString(); + + if (!verifyWebhook(payload, signature, process.env.WEBHOOK_SECRET)) { + return res.status(401).send('Invalid signature'); + } + + const event = JSON.parse(payload); + console.log(`Received ${event.event} for secret ${event.data.secretId}`); + + res.status(200).send('OK'); +}); +``` + +### Python Example + +```python +import hmac +import hashlib + +def verify_webhook(payload: bytes, signature: str, secret: str) -> bool: + expected = 'sha256=' + hmac.new( + secret.encode(), + payload, + hashlib.sha256 + ).hexdigest() + + return hmac.compare_digest(signature, expected) + +# Flask example +@app.route('/webhook', methods=['POST']) +def webhook(): + signature = request.headers.get('X-Hemmelig-Signature') + payload = request.get_data() + + if not verify_webhook(payload, signature, os.environ['WEBHOOK_SECRET']): + return 'Invalid signature', 401 + + event = request.get_json() + print(f"Received {event['event']} for secret {event['data']['secretId']}") + + return 'OK', 200 +``` + +### Go Example + +```go +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" +) + +func verifyWebhook(payload []byte, signature, secret string) bool { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(signature), []byte(expected)) +} + +func webhookHandler(w http.ResponseWriter, r *http.Request) { + signature := r.Header.Get("X-Hemmelig-Signature") + payload, _ := io.ReadAll(r.Body) + + if !verifyWebhook(payload, signature, os.Getenv("WEBHOOK_SECRET")) { + http.Error(w, "Invalid signature", http.StatusUnauthorized) + return + } + + // Process webhook... + w.WriteHeader(http.StatusOK) +} +``` + +## Integration Examples + +### Slack Notification + +Send a message to Slack when a secret is viewed: + +```javascript +app.post('/webhook', async (req, res) => { + const event = req.body; + + if (event.event === 'secret.viewed') { + await fetch(process.env.SLACK_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `🔓 Secret ${event.data.secretId} was viewed. ${event.data.viewsRemaining} views remaining.`, + }), + }); + } + + res.status(200).send('OK'); +}); +``` + +### Discord Notification + +```javascript +if (event.event === 'secret.burned') { + await fetch(process.env.DISCORD_WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + embeds: [ + { + title: '🔥 Secret Burned', + description: `Secret \`${event.data.secretId}\` has been permanently deleted.`, + color: 0xff6b6b, + timestamp: event.timestamp, + }, + ], + }), + }); +} +``` + +## Best Practices + +1. **Always use HTTPS** for your webhook endpoint +2. **Always verify signatures** to prevent spoofed requests +3. **Respond quickly** (< 5 seconds) to avoid timeouts +4. **Use a queue** for heavy processing to avoid blocking +5. **Log webhook events** for debugging and audit trails +6. **Handle retries** gracefully (webhooks are fire-and-forget, no retries) + +## Troubleshooting + +**Webhooks not being received?** + +- Check that webhooks are enabled in Instance Settings +- Verify your webhook URL is accessible from the Hemmelig server +- Check server logs for any error messages + +**Invalid signature errors?** + +- Ensure you're using the raw request body (not parsed JSON) for verification +- Check that your webhook secret matches exactly +- Make sure you're comparing the full signature including the `sha256=` prefix diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0767d14 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,25 @@ +import js from '@eslint/js'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + } +); diff --git a/helm/hemmelig/.helmignore b/helm/hemmelig/.helmignore new file mode 100644 index 0000000..414bb6e --- /dev/null +++ b/helm/hemmelig/.helmignore @@ -0,0 +1,18 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.swp +*.bak +*.tmp +*.orig +*~ +.project +.idea/ +*.tmproj +.vscode/ diff --git a/helm/hemmelig/Chart.yaml b/helm/hemmelig/Chart.yaml new file mode 100644 index 0000000..9f877ce --- /dev/null +++ b/helm/hemmelig/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: hemmelig +description: A Helm chart for Hemmelig - Encrypted secret sharing application +type: application +version: 1.0.0 +appVersion: "7.0.0" +keywords: + - secrets + - encryption + - security + - sharing +home: https://hemmelig.app +sources: + - https://github.com/HemmeligOrg/Hemmelig.app +maintainers: + - name: HemmeligOrg + url: https://github.com/HemmeligOrg diff --git a/helm/hemmelig/templates/NOTES.txt b/helm/hemmelig/templates/NOTES.txt new file mode 100644 index 0000000..ef2d4b6 --- /dev/null +++ b/helm/hemmelig/templates/NOTES.txt @@ -0,0 +1,38 @@ +Thank you for installing {{ .Chart.Name }}! + +{{- if .Values.ingress.enabled }} +Your Hemmelig instance is available at: +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} +Get the application URL by running: + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hemmelig.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} +Get the application URL by running: + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hemmelig.fullname" . }} +{{- else if contains "ClusterIP" .Values.service.type }} +Get the application URL by running: + kubectl --namespace {{ .Release.Namespace }} port-forward svc/{{ include "hemmelig.fullname" . }} 3000:{{ .Values.service.port }} + echo "Visit http://127.0.0.1:3000" +{{- end }} + +{{- if not .Values.config.betterAuthSecret }} + +WARNING: You have not set config.betterAuthSecret! +Please set this value or use existingSecret to provide authentication secrets. +Generate a secret with: openssl rand -base64 32 +{{- end }} + +{{- if not .Values.config.betterAuthUrl }} + +WARNING: You have not set config.betterAuthUrl! +This is required for OAuth authentication and proper cookie handling. +{{- end }} + +For more information, visit: https://github.com/HemmeligOrg/Hemmelig.app diff --git a/helm/hemmelig/templates/_helpers.tpl b/helm/hemmelig/templates/_helpers.tpl new file mode 100644 index 0000000..411369b --- /dev/null +++ b/helm/hemmelig/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "hemmelig.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "hemmelig.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "hemmelig.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "hemmelig.labels" -}} +helm.sh/chart: {{ include "hemmelig.chart" . }} +{{ include "hemmelig.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "hemmelig.selectorLabels" -}} +app.kubernetes.io/name: {{ include "hemmelig.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "hemmelig.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "hemmelig.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/helm/hemmelig/templates/deployment.yaml b/helm/hemmelig/templates/deployment.yaml new file mode 100644 index 0000000..86ddc96 --- /dev/null +++ b/helm/hemmelig/templates/deployment.yaml @@ -0,0 +1,318 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "hemmelig.fullname" . }} + labels: + {{- include "hemmelig.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate + selector: + matchLabels: + {{- include "hemmelig.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "hemmelig.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "hemmelig.serviceAccountName" . }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: 3000 + protocol: TCP + env: + - name: NODE_ENV + value: "production" + - name: DATABASE_URL + value: "file:/app/database/hemmelig.db" + {{- if .Values.config.betterAuthUrl }} + - name: BETTER_AUTH_URL + value: {{ .Values.config.betterAuthUrl | quote }} + {{- end }} + {{- if .Values.config.baseUrl }} + - name: HEMMELIG_BASE_URL + value: {{ .Values.config.baseUrl | quote }} + {{- end }} + {{- if .Values.existingSecret }} + - name: BETTER_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: BETTER_AUTH_SECRET + {{- else if .Values.config.betterAuthSecret }} + - name: BETTER_AUTH_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: BETTER_AUTH_SECRET + {{- end }} + {{- if or (.Values.oauth.github.enabled) (.Values.oauth.google.enabled) (.Values.oauth.microsoft.enabled) (.Values.oauth.discord.enabled) (.Values.oauth.gitlab.enabled) (.Values.oauth.apple.enabled) (.Values.oauth.twitter.enabled) (.Values.oauth.generic) }} + {{- if .Values.existingSecret }} + # OAuth variables from existing secret + {{- if .Values.oauth.github.enabled }} + - name: HEMMELIG_AUTH_GITHUB_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GITHUB_ID + - name: HEMMELIG_AUTH_GITHUB_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GITHUB_SECRET + {{- end }} + {{- if .Values.oauth.google.enabled }} + - name: HEMMELIG_AUTH_GOOGLE_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GOOGLE_ID + - name: HEMMELIG_AUTH_GOOGLE_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GOOGLE_SECRET + {{- end }} + {{- if .Values.oauth.microsoft.enabled }} + - name: HEMMELIG_AUTH_MICROSOFT_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_MICROSOFT_ID + - name: HEMMELIG_AUTH_MICROSOFT_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_MICROSOFT_SECRET + - name: HEMMELIG_AUTH_MICROSOFT_TENANT_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_MICROSOFT_TENANT_ID + optional: true + {{- end }} + {{- if .Values.oauth.discord.enabled }} + - name: HEMMELIG_AUTH_DISCORD_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_DISCORD_ID + - name: HEMMELIG_AUTH_DISCORD_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_DISCORD_SECRET + {{- end }} + {{- if .Values.oauth.gitlab.enabled }} + - name: HEMMELIG_AUTH_GITLAB_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GITLAB_ID + - name: HEMMELIG_AUTH_GITLAB_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GITLAB_SECRET + - name: HEMMELIG_AUTH_GITLAB_ISSUER + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GITLAB_ISSUER + optional: true + {{- end }} + {{- if .Values.oauth.apple.enabled }} + - name: HEMMELIG_AUTH_APPLE_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_APPLE_ID + - name: HEMMELIG_AUTH_APPLE_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_APPLE_SECRET + {{- end }} + {{- if .Values.oauth.twitter.enabled }} + - name: HEMMELIG_AUTH_TWITTER_ID + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_TWITTER_ID + - name: HEMMELIG_AUTH_TWITTER_SECRET + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_TWITTER_SECRET + {{- end }} + {{- if .Values.oauth.generic }} + - name: HEMMELIG_AUTH_GENERIC_OAUTH + valueFrom: + secretKeyRef: + name: {{ .Values.existingSecret }} + key: HEMMELIG_AUTH_GENERIC_OAUTH + {{- end }} + {{- else }} + # OAuth variables from default secret (when not using existingSecret) + {{- if .Values.oauth.github.enabled }} + - name: HEMMELIG_AUTH_GITHUB_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GITHUB_ID + - name: HEMMELIG_AUTH_GITHUB_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GITHUB_SECRET + {{- end }} + {{- if .Values.oauth.google.enabled }} + - name: HEMMELIG_AUTH_GOOGLE_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GOOGLE_ID + - name: HEMMELIG_AUTH_GOOGLE_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GOOGLE_SECRET + {{- end }} + {{- if .Values.oauth.microsoft.enabled }} + - name: HEMMELIG_AUTH_MICROSOFT_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_MICROSOFT_ID + - name: HEMMELIG_AUTH_MICROSOFT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_MICROSOFT_SECRET + - name: HEMMELIG_AUTH_MICROSOFT_TENANT_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_MICROSOFT_TENANT_ID + optional: true + {{- end }} + {{- if .Values.oauth.discord.enabled }} + - name: HEMMELIG_AUTH_DISCORD_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_DISCORD_ID + - name: HEMMELIG_AUTH_DISCORD_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_DISCORD_SECRET + {{- end }} + {{- if .Values.oauth.gitlab.enabled }} + - name: HEMMELIG_AUTH_GITLAB_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GITLAB_ID + - name: HEMMELIG_AUTH_GITLAB_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GITLAB_SECRET + - name: HEMMELIG_AUTH_GITLAB_ISSUER + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GITLAB_ISSUER + optional: true + {{- end }} + {{- if .Values.oauth.apple.enabled }} + - name: HEMMELIG_AUTH_APPLE_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_APPLE_ID + - name: HEMMELIG_AUTH_APPLE_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_APPLE_SECRET + {{- end }} + {{- if .Values.oauth.twitter.enabled }} + - name: HEMMELIG_AUTH_TWITTER_ID + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_TWITTER_ID + - name: HEMMELIG_AUTH_TWITTER_SECRET + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_TWITTER_SECRET + {{- end }} + {{- if .Values.oauth.generic }} + - name: HEMMELIG_AUTH_GENERIC_OAUTH + valueFrom: + secretKeyRef: + name: {{ include "hemmelig.fullname" . }} + key: HEMMELIG_AUTH_GENERIC_OAUTH + {{- end }} + {{- end }} + {{- end }} + {{- with .Values.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /app/database + - name: uploads + mountPath: /app/uploads + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: data + {{- if .Values.persistence.data.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.data.existingClaim | default (printf "%s-data" (include "hemmelig.fullname" .)) }} + {{- else }} + emptyDir: {} + {{- end }} + - name: uploads + {{- if .Values.persistence.uploads.enabled }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.uploads.existingClaim | default (printf "%s-uploads" (include "hemmelig.fullname" .)) }} + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/helm/hemmelig/templates/ingress.yaml b/helm/hemmelig/templates/ingress.yaml new file mode 100644 index 0000000..972092f --- /dev/null +++ b/helm/hemmelig/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "hemmelig.fullname" . }} + labels: + {{- include "hemmelig.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "hemmelig.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/hemmelig/templates/pvc.yaml b/helm/hemmelig/templates/pvc.yaml new file mode 100644 index 0000000..2c20e26 --- /dev/null +++ b/helm/hemmelig/templates/pvc.yaml @@ -0,0 +1,35 @@ +{{- if and .Values.persistence.data.enabled (not .Values.persistence.data.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "hemmelig.fullname" . }}-data + labels: + {{- include "hemmelig.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.data.accessMode }} + {{- if .Values.persistence.data.storageClass }} + storageClassName: {{ .Values.persistence.data.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.data.size }} +{{- end }} +--- +{{- if and .Values.persistence.uploads.enabled (not .Values.persistence.uploads.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "hemmelig.fullname" . }}-uploads + labels: + {{- include "hemmelig.labels" . | nindent 4 }} +spec: + accessModes: + - {{ .Values.persistence.uploads.accessMode }} + {{- if .Values.persistence.uploads.storageClass }} + storageClassName: {{ .Values.persistence.uploads.storageClass }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.uploads.size }} +{{- end }} diff --git a/helm/hemmelig/templates/secret.yaml b/helm/hemmelig/templates/secret.yaml new file mode 100644 index 0000000..969b530 --- /dev/null +++ b/helm/hemmelig/templates/secret.yaml @@ -0,0 +1,78 @@ +{{- if and (or .Values.config.betterAuthSecret (or .Values.oauth.github.enabled (or .Values.oauth.google.enabled (or .Values.oauth.microsoft.enabled (or .Values.oauth.discord.enabled (or .Values.oauth.gitlab.enabled (or .Values.oauth.apple.enabled (or .Values.oauth.twitter.enabled .Values.oauth.generic)))))))) (not .Values.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "hemmelig.fullname" . }} + labels: + {{- include "hemmelig.labels" . | nindent 4 }} +type: Opaque +data: + {{- if .Values.config.betterAuthSecret }} + BETTER_AUTH_SECRET: {{ .Values.config.betterAuthSecret | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.github.enabled }} + {{- if .Values.oauth.github.clientId }} + HEMMELIG_AUTH_GITHUB_ID: {{ .Values.oauth.github.clientId | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.github.clientSecret }} + HEMMELIG_AUTH_GITHUB_SECRET: {{ .Values.oauth.github.clientSecret | b64enc | quote }} + {{- end }} + {{- end }} + {{- if .Values.oauth.google.enabled }} + {{- if .Values.oauth.google.clientId }} + HEMMELIG_AUTH_GOOGLE_ID: {{ .Values.oauth.google.clientId | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.google.clientSecret }} + HEMMELIG_AUTH_GOOGLE_SECRET: {{ .Values.oauth.google.clientSecret | b64enc | quote }} + {{- end }} + {{- end }} + {{- if .Values.oauth.microsoft.enabled }} + {{- if .Values.oauth.microsoft.clientId }} + HEMMELIG_AUTH_MICROSOFT_ID: {{ .Values.oauth.microsoft.clientId | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.microsoft.clientSecret }} + HEMMELIG_AUTH_MICROSOFT_SECRET: {{ .Values.oauth.microsoft.clientSecret | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.microsoft.tenantId }} + HEMMELIG_AUTH_MICROSOFT_TENANT_ID: {{ .Values.oauth.microsoft.tenantId | b64enc | quote }} + {{- end }} + {{- end }} + {{- if .Values.oauth.discord.enabled }} + {{- if .Values.oauth.discord.clientId }} + HEMMELIG_AUTH_DISCORD_ID: {{ .Values.oauth.discord.clientId | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.discord.clientSecret }} + HEMMELIG_AUTH_DISCORD_SECRET: {{ .Values.oauth.discord.clientSecret | b64enc | quote }} + {{- end }} + {{- end }} + {{- if .Values.oauth.gitlab.enabled }} + {{- if .Values.oauth.gitlab.clientId }} + HEMMELIG_AUTH_GITLAB_ID: {{ .Values.oauth.gitlab.clientId | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.gitlab.clientSecret }} + HEMMELIG_AUTH_GITLAB_SECRET: {{ .Values.oauth.gitlab.clientSecret | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.gitlab.issuer }} + HEMMELIG_AUTH_GITLAB_ISSUER: {{ .Values.oauth.gitlab.issuer | b64enc | quote }} + {{- end }} + {{- end }} + {{- if .Values.oauth.apple.enabled }} + {{- if .Values.oauth.apple.clientId }} + HEMMELIG_AUTH_APPLE_ID: {{ .Values.oauth.apple.clientId | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.apple.clientSecret }} + HEMMELIG_AUTH_APPLE_SECRET: {{ .Values.oauth.apple.clientSecret | b64enc | quote }} + {{- end }} + {{- end }} + {{- if .Values.oauth.twitter.enabled }} + {{- if .Values.oauth.twitter.clientId }} + HEMMELIG_AUTH_TWITTER_ID: {{ .Values.oauth.twitter.clientId | b64enc | quote }} + {{- end }} + {{- if .Values.oauth.twitter.clientSecret }} + HEMMELIG_AUTH_TWITTER_SECRET: {{ .Values.oauth.twitter.clientSecret | b64enc | quote }} + {{- end }} + {{- end }} + {{- if .Values.oauth.generic }} + HEMMELIG_AUTH_GENERIC_OAUTH: {{ .Values.oauth.generic | b64enc | quote }} + {{- end }} +{{- end }} diff --git a/helm/hemmelig/templates/service.yaml b/helm/hemmelig/templates/service.yaml new file mode 100644 index 0000000..d1e6108 --- /dev/null +++ b/helm/hemmelig/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "hemmelig.fullname" . }} + labels: + {{- include "hemmelig.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "hemmelig.selectorLabels" . | nindent 4 }} diff --git a/helm/hemmelig/templates/serviceaccount.yaml b/helm/hemmelig/templates/serviceaccount.yaml new file mode 100644 index 0000000..da6864e --- /dev/null +++ b/helm/hemmelig/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "hemmelig.serviceAccountName" . }} + labels: + {{- include "hemmelig.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/helm/hemmelig/values.yaml b/helm/hemmelig/values.yaml new file mode 100644 index 0000000..6a3873b --- /dev/null +++ b/helm/hemmelig/values.yaml @@ -0,0 +1,153 @@ +# Default values for hemmelig + +replicaCount: 1 + +image: + repository: hemmeligapp/hemmelig + tag: "v7" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# Required configuration +config: + # Generate with: openssl rand -base64 32 + betterAuthSecret: "" + # Public URL of your instance (required for OAuth and cookie handling) + betterAuthUrl: "" + # Base URL for OAuth callbacks (required for social login) + baseUrl: "" + +# Additional environment variables +env: [] + # - name: HEMMELIG_ANALYTICS_ENABLED + # value: "true" + +# Use existing secret for sensitive values +existingSecret: "" +# Keys expected in the secret: +# BETTER_AUTH_SECRET +# HEMMELIG_ANALYTICS_HMAC_SECRET (optional) +# HEMMELIG_AUTH_GITHUB_ID, HEMMELIG_AUTH_GITHUB_SECRET (optional) +# HEMMELIG_AUTH_GOOGLE_ID, HEMMELIG_AUTH_GOOGLE_SECRET (optional) +# HEMMELIG_AUTH_MICROSOFT_ID, HEMMELIG_AUTH_MICROSOFT_SECRET, HEMMELIG_AUTH_MICROSOFT_TENANT_ID (optional) +# HEMMELIG_AUTH_DISCORD_ID, HEMMELIG_AUTH_DISCORD_SECRET (optional) +# HEMMELIG_AUTH_GITLAB_ID, HEMMELIG_AUTH_GITLAB_SECRET, HEMMELIG_AUTH_GITLAB_ISSUER (optional) +# HEMMELIG_AUTH_APPLE_ID, HEMMELIG_AUTH_APPLE_SECRET (optional) +# HEMMELIG_AUTH_TWITTER_ID, HEMMELIG_AUTH_TWITTER_SECRET (optional) +# HEMMELIG_AUTH_GENERIC_OAUTH (optional) + +# OAuth / Social Login Configuration +oauth: + github: + enabled: false + clientId: "" + clientSecret: "" + google: + enabled: false + clientId: "" + clientSecret: "" + microsoft: + enabled: false + clientId: "" + clientSecret: "" + tenantId: "" # Optional + discord: + enabled: false + clientId: "" + clientSecret: "" + gitlab: + enabled: false + clientId: "" + clientSecret: "" + issuer: "" # Optional, for self-hosted GitLab (e.g., https://gitlab.example.com) + apple: + enabled: false + clientId: "" + clientSecret: "" + twitter: + enabled: false + clientId: "" + clientSecret: "" + generic: "" + # Example: '[{"providerId":"authentik","discoveryUrl":"https://auth.example.com/.well-known/openid-configuration","clientId":"client-id","clientSecret":"secret","scopes":["openid","profile","email"]}]' + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + + + +service: + type: ClusterIP + port: 3000 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: hemmelig.local + paths: + - path: / + pathType: Prefix + tls: [] + # - secretName: hemmelig-tls + # hosts: + # - hemmelig.local + +resources: {} + # limits: + # cpu: 500m + # memory: 512Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# Persistence for SQLite database and uploads +persistence: + data: + enabled: true + size: 1Gi + storageClass: "" + accessMode: ReadWriteOnce + # existingClaim: "" + uploads: + enabled: true + size: 5Gi + storageClass: "" + accessMode: ReadWriteOnce + # existingClaim: "" + +livenessProbe: + httpGet: + path: /api/healthz + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /api/healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/index.html b/index.html new file mode 100644 index 0000000..cc4e4ea --- /dev/null +++ b/index.html @@ -0,0 +1,51 @@ +<!doctype html> +<html lang="es" class="dark"> + <head> + <meta charset="utf-8" /> + <title>paste.es - Comparte secretos de forma segura + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..4248b76 Binary files /dev/null and b/logo.png differ diff --git a/logo.svg b/logo.svg new file mode 100644 index 0000000..031b1f4 --- /dev/null +++ b/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/logo_color.png b/logo_color.png new file mode 100644 index 0000000..c35bdca Binary files /dev/null and b/logo_color.png differ diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..6d3934a --- /dev/null +++ b/mise.toml @@ -0,0 +1,4 @@ +[tools] +go = "latest" +helm = "latest" +node = "latest" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..08048b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9673 @@ +{ + "name": "hemmelig-app", + "version": "7.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hemmelig-app", + "version": "7.0.0", + "dependencies": { + "@hono/node-server": "^1.19.7", + "@hono/swagger-ui": "^0.5.2", + "@hono/zod-openapi": "^1.1.5", + "@hono/zod-validator": "^0.7.5", + "@prisma/adapter-better-sqlite3": "^7.4.1", + "@prisma/client": "^7.4.1", + "@tailwindcss/vite": "^4.1.18", + "argon2": "^0.44.0", + "better-auth": "^1.4.6", + "better-sqlite3": "^12.5.0", + "croner": "^9.1.0", + "dlv": "^1.1.3", + "dotenv": "^17.2.3", + "hono": "^4.11.7", + "hono-rate-limiter": "^0.4.2", + "ip-address": "^10.1.0", + "ip-range-check": "^0.2.0", + "is-cidr": "^6.0.1", + "is-ip": "^5.0.1", + "isbot": "^5.1.32", + "nanoid": "^5.1.6", + "prisma": "^7.4.1", + "prom-client": "^15.1.3", + "react-markdown": "^10.1.0", + "ua-parser-js": "^2.0.7", + "validator": "^13.15.23", + "zod": "^4.1.13" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@hono/vite-dev-server": "^0.23.0", + "@playwright/test": "^1.57.0", + "@tabler/icons-react": "^3.35.0", + "@tailwindcss/typography": "^0.5.19", + "@tiptap/extension-character-count": "^3.13.0", + "@tiptap/extension-color": "^3.13.0", + "@tiptap/extension-link": "^3.13.0", + "@tiptap/extension-list-item": "^3.13.0", + "@tiptap/extension-text-style": "^3.13.0", + "@tiptap/react": "^3.13.0", + "@tiptap/starter-kit": "^3.13.0", + "@types/dlv": "^1.1.5", + "@types/node": "^25.0.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/validator": "^13.15.10", + "@vitejs/plugin-react": "^5.1.2", + "autoprefixer": "^10.4.22", + "clsx": "^2.1.1", + "eslint": "^9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "generate-password-browser": "^1.1.0", + "globals": "^16.5.0", + "husky": "^9.1.7", + "i18next": "^25.7.2", + "i18next-browser-languagedetector": "^8.2.0", + "lint-staged": "^16.2.7", + "lucide-react": "^0.561.0", + "postcss": "^8.5.6", + "prettier": "^3.7.4", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-prisma": "^5.0.0", + "qrcode.react": "^4.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-dropzone": "^14.3.8", + "react-i18next": "^16.5.0", + "react-router-dom": "^7.12.0", + "recharts": "^3.5.1", + "sonner": "^2.0.7", + "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7", + "zustand": "^5.0.9" + } + }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.4.1.tgz", + "integrity": "sha512-WmJUsFINbnWxGvHSd16aOjgKf+5GsfdxruO2YDLcgplsidakCauik1lhlk83YDH06265Yd1XtUyF24o09uygpw==", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@better-auth/core": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.4.19.tgz", + "integrity": "sha512-uADLHG1jc5BnEJi7f6ijUN5DmPPRSj++7m/G19z3UqA3MVCo4Y4t1MMa4IIxLCqGDFv22drdfxescgW+HnIowA==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "better-call": "1.1.8", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1" + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.4.19.tgz", + "integrity": "sha512-ApGNS7olCTtDpKF8Ow3Z+jvFAirOj7c4RyFUpu8axklh3mH57ndpfUAUjhgA8UVoaaH/mnm/Tl884BlqiewLyw==", + "dependencies": { + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21" + }, + "peerDependencies": { + "@better-auth/core": "1.4.19" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==", + "license": "MIT" + }, + "node_modules/@better-fetch/fetch": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.1.21.tgz", + "integrity": "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==" + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", + "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", + "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@hono/swagger-ui": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@hono/swagger-ui/-/swagger-ui-0.5.3.tgz", + "integrity": "sha512-Hn90DOOJ62ICJQplQvCDVpi9Jcn6EhtRaiffyJIS53wA5RmRLtMCDQGVc0bor8vQD7JIwpkweWjs+3cycp+IvA==", + "license": "MIT", + "peerDependencies": { + "hono": ">=4.0.0" + } + }, + "node_modules/@hono/vite-dev-server": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@hono/vite-dev-server/-/vite-dev-server-0.23.0.tgz", + "integrity": "sha512-tHV86xToed9Up0j/dubQW2PDP4aYNFDSfQrjcV6Ra7bqCGrxhtg/zZBmbgSco3aTxKOEPzDXKK+6seAAfsbIXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.14.2", + "minimatch": "^9.0.3" + }, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "*", + "miniflare": "*", + "wrangler": "*" + }, + "peerDependenciesMeta": { + "hono": { + "optional": false + }, + "miniflare": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, + "node_modules/@hono/zod-openapi": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@hono/zod-openapi/-/zod-openapi-1.2.2.tgz", + "integrity": "sha512-va6vsL23wCJ1d0Vd+vGL1XOt+wPwItxirYafuhlW9iC2MstYr2FvsI7mctb45eBTjZfkqB/3LYDJEppPjOEiHw==", + "license": "MIT", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.4.1", + "@hono/zod-validator": "^0.7.6", + "openapi3-ts": "^4.5.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.3.6", + "zod": "^4.0.0" + } + }, + "node_modules/@hono/zod-validator": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@hono/zod-validator/-/zod-validator-0.7.6.tgz", + "integrity": "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==", + "license": "MIT", + "peerDependencies": { + "hono": ">=3.9.0", + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", + "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@noble/ciphers": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", + "integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@prisma/adapter-better-sqlite3": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-better-sqlite3/-/adapter-better-sqlite3-7.4.1.tgz", + "integrity": "sha512-qYcbk5gSfKfedVzEJHFTpIr7kybEmoJ+eRE1/wzdcZozDyHlEfnqKbhx+IEPcG5n7qSj4Zvx+G1uT0VXs9nF/w==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.4.1", + "better-sqlite3": "^12.6.0" + } + }, + "node_modules/@prisma/client": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.1.tgz", + "integrity": "sha512-pgIll2W1NVdof37xLeyySW+yfQ4rI+ERGCRwnO3BjVOx42GpYq6jhTyuALK8VKirvJJIvImgfGDA2qwhYVvMuA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.4.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.1.tgz", + "integrity": "sha512-8fy74OMYC7mt9cJ2MncIDk1awPRgmtXVvwTN2FlW4JVhbck8Dgt0wTkhPG85myfj4ZeP2stjF9Sdg12n5HrpQg==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.1.tgz", + "integrity": "sha512-vteSXm8N46bo3FW9MhPGVHAj+KRgrR6TWtlSk6GqToCKjTnOexXdPZyiDyEsfVW38YhqEmVl6w/6iHN8uYVJcw==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.1.tgz", + "integrity": "sha512-qEtzO8oLouRv18JDQUC3G3Gnv+fGVscHZm/x1DBB/WT+kOvPDQLM2woX6IGgWnSMYYlrxjuALshT7G/blvY0bQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", + "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite-socket": "0.0.20", + "@electric-sql/pglite-tools": "0.2.20", + "@hono/node-server": "1.19.9", + "@mrleebo/prisma-ast": "0.13.1", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "4.11.4", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/dev/node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.1.tgz", + "integrity": "sha512-gEZOC2tnlHaZNbHUdbK8YvQphq2tKq/Ovu1YixJ/hPSutDAvNzC3R+xUeBuJ4AJp236eELMzwxb7rgo3UbRkTg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1" + } + }, + "node_modules/@prisma/engines": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.1.tgz", + "integrity": "sha512-BZEBdHvNJx5PzIG37EI/Zi5UUI5hGWjkYsQmKa7OIK6evAvebOTwutjS/VRI6cA6grmA52eLZR+oekGRMqkKxQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1", + "@prisma/engines-version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", + "@prisma/fetch-engine": "7.4.1", + "@prisma/get-platform": "7.4.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3.tgz", + "integrity": "sha512-fUxVd1TjOW8K4XsZ8dAm88sDW5Ry7AxWDfsYEWwScS6Fjo3caKC6hgNumUfsmsy0Il9LjDn5X0PpVXNt3iwayw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.1.tgz", + "integrity": "sha512-kN4tmkQzlgm/KtE+jTNSYjsDxxe/5i6GApPI32BN9T0tlgsgSBtDJbjGBICttkAIjsh73dXf8raPKxO/2n2UUg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.1.tgz", + "integrity": "sha512-Z9kbuxX2bvEsyeS3LZEiEnxG0lVtZbpYgaAnPj69N+A9f2De8Lta0EoFtld9zhfERVPIQWhSWUc8himky3qYdA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1", + "@prisma/engines-version": "7.5.0-4.55ae170b1ced7fc6ed07a15f110549408c501bb3", + "@prisma/get-platform": "7.4.1" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.1.tgz", + "integrity": "sha512-kN4tmkQzlgm/KtE+jTNSYjsDxxe/5i6GApPI32BN9T0tlgsgSBtDJbjGBICttkAIjsh73dXf8raPKxO/2n2UUg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/prisma-schema-wasm": { + "version": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584", + "resolved": "https://registry.npmjs.org/@prisma/prisma-schema-wasm/-/prisma-schema-wasm-4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584.tgz", + "integrity": "sha512-JFdsnSgBPN8reDTLOI9Vh/6ccCb2aD1LbY/LWQnkcIgNo6IdpzvuM+qRVbBuA6IZP2SdqQI8Lu6RL2P8EFBQUA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", + "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tabler/icons": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.37.1.tgz", + "integrity": "sha512-neLCWkuyNHEPXCyYu6nbN4S3g/59BTa4qyITAugYVpq1YzYNDOZooW7/vRWH98ZItXAudxdKU8muFT7y1PqzuA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.37.1.tgz", + "integrity": "sha512-R7UE71Jji7i4Su56Y9zU1uYEBakUejuDJvyuYVmBuUoqp/x3Pn4cv2huarexR3P0GJ2eHg4rUj9l5zccqS6K/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tabler/icons": "" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tiptap/core": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", + "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.20.0.tgz", + "integrity": "sha512-LQzn6aGtL4WXz2+rYshl/7/VnP2qJTpD7fWL96GXAzhqviPEY1bJES7poqJb3MU/gzl8VJUVzVzU1VoVfUKlbA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.20.0.tgz", + "integrity": "sha512-sQklEWiyf58yDjiHtm5vmkVjfIc/cBuSusmCsQ0q9vGYnEF1iOHKhGpvnCeEXNeqF3fiJQRlquzt/6ymle3Iwg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.20.0.tgz", + "integrity": "sha512-MDosUfs8Tj+nwg8RC+wTMWGkLJORXmbR6YZgbiX4hrc7G90Gopdd6kj6ht5/T8t7dLLaX7N0+DEHdUEPGED7dw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.0.tgz", + "integrity": "sha512-OcKMeopBbqWzhSi6o8nNz0aayogg1sfOAhto3NxJu3Ya32dwBFqmHXSYM6uW4jOphNvVPyjiq9aNRh3qTdd1dw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-character-count": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.20.0.tgz", + "integrity": "sha512-WxE0HgntJfkpaCy7u7ANL7jwqygSIu1wc7eKL78sp1jr0QeyQYj5Addq7h//fpr7OI9+V8v55tM2+qd8RiI77Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.20.0.tgz", + "integrity": "sha512-TYDWFeSQ9umiyrqsT6VecbuhL8XIHkUhO+gEk0sVvH67ZLwjFDhAIIgWIr1/dbIGPcvMZM19E7xUUhAdIaXaOQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz", + "integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-color": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.20.0.tgz", + "integrity": "sha512-KGXQ3I18uLHQ61FZpXDu0gH6+0jqmDkVwkPNXmM3oPNDSH80SG5UeZlrXi/PwtlusePJ3dFHtoQ1g6j2bJUssg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-text-style": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.20.0.tgz", + "integrity": "sha512-oJfLIG3vAtZo/wg29WiBcyWt22KUgddpP8wqtCE+kY5Dw8znLR9ehNmVWlSWJA5OJUMO0ntAHx4bBT+I2MBd5w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.0.tgz", + "integrity": "sha512-d+cxplRlktVgZPwatnc34IArlppM0IFKS1J5wLk+ba1jidizsbMVh45tP/BTK2flhyfRqcNoB5R0TArhUpbkNQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.20.0.tgz", + "integrity": "sha512-rYs4Bv5pVjqZ/2vvR6oe7ammZapkAwN51As/WDbemvYDjfOGRqK58qGauUjYZiDzPOEIzI2mxGwsZ4eJhPW4Ig==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.0.tgz", + "integrity": "sha512-P/LasfvG9/qFq43ZAlNbAnPnXC+/RJf49buTrhtFvI9Zg0+Lbpjx1oh6oMHB19T88Y28KtrckfFZ8aTSUWDq6w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.20.0.tgz", + "integrity": "sha512-rqvhMOw4f+XQmEthncbvDjgLH6fz8L9splnKZC7OeS0eX8b0qd7+xI1u5kyxF3KA2Z0BnigES++jjWuecqV6mA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.20.0.tgz", + "integrity": "sha512-JgJhurnCe3eN6a0lEsNQM/46R1bcwzwWWZEFDSb1P9dR8+t1/5v7cMZWsSInpD7R4/74iJn0+M5hcXLwCmBmYA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.0.tgz", + "integrity": "sha512-6uvcutFMv+9wPZgptDkbRDjAm3YVxlibmkhWD5GuaWwS9L/yUtobpI3GycujRSUZ8D3q6Q9J7LqpmQtQRTalWA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.0.tgz", + "integrity": "sha512-/DhnKQF8yN8RxtuL8abZ28wd5281EaGoE2Oha35zXSOF1vNYnbyt8Ymkv/7u1BcWEWTvRPgaju0YCGXisPRLYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.20.0.tgz", + "integrity": "sha512-qI/5A+R0ZWBxo/8HxSn1uOyr7odr3xHBZ/gzOR1GUJaZqjlJxkWFX0RtXMbLKEGEvT25o345cF7b0wFznEh8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz", + "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.20.0.tgz", + "integrity": "sha512-qEtjaaGPuqaFB4VpLrGDoIe9RHnckxPfu6d3rc22ap6TAHCDyRv05CEyJogqccnFceG/v5WN4znUBER8RWnWHA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.0.tgz", + "integrity": "sha512-Z4GvKy04Ms4cLFN+CY6wXswd36xYsT2p/YL0V89LYFMZTerOeTjFYlndzn6svqL8NV1PRT5Diw4WTTxJSmcJPA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.0.tgz", + "integrity": "sha512-jVKnJvrizLk7etwBMfyoj6H2GE4M+PD4k7Bwp6Bh1ohBWtfIA1TlngdS842Mx5i1VB2e3UWIwr8ZH46gl6cwMA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.20.0.tgz", + "integrity": "sha512-mM99zK4+RnEXIMCv6akfNATAs0Iija6FgyFA9J9NZ6N4o8y9QiNLLa6HjLpAC+W+VoCgQIekyoF/Q9ftxmAYDQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.20.0.tgz", + "integrity": "sha512-0vcTZRRAiDfon3VM1mHBr9EFmTkkUXMhm0Xtdtn0bGe+sIqufyi+hUYTEw93EQOD9XNsPkrud6jzQNYpX2H3AQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.20.0.tgz", + "integrity": "sha512-tf8bE8tSaOEWabCzPm71xwiUhyMFKqY9jkP5af3Kr1/F45jzZFIQAYZooHI/+zCHRrgJ99MQHKHe1ZNvODrKHQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.20.0.tgz", + "integrity": "sha512-zyWW1a6W+kaXAn3wv2svJ1XuVMapujftvH7Xn2Q3QmKKiDkO+NiFkrGe8BhMopu8Im51nO3NylIgVA0X1mS1rQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.20.0.tgz", + "integrity": "sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz", + "integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", + "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.20.0.tgz", + "integrity": "sha512-jFLNzkmn18zqefJwPje0PPd9VhZ7Oy28YHiSvSc7YpBnQIbuN/HIxZ2lrOsKyEHta0WjRZjfU5X1pGxlbcGwOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.20.0", + "@tiptap/extension-floating-menu": "^3.20.0" + }, + "peerDependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/pm": "^3.20.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.20.0.tgz", + "integrity": "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tiptap/core": "^3.20.0", + "@tiptap/extension-blockquote": "^3.20.0", + "@tiptap/extension-bold": "^3.20.0", + "@tiptap/extension-bullet-list": "^3.20.0", + "@tiptap/extension-code": "^3.20.0", + "@tiptap/extension-code-block": "^3.20.0", + "@tiptap/extension-document": "^3.20.0", + "@tiptap/extension-dropcursor": "^3.20.0", + "@tiptap/extension-gapcursor": "^3.20.0", + "@tiptap/extension-hard-break": "^3.20.0", + "@tiptap/extension-heading": "^3.20.0", + "@tiptap/extension-horizontal-rule": "^3.20.0", + "@tiptap/extension-italic": "^3.20.0", + "@tiptap/extension-link": "^3.20.0", + "@tiptap/extension-list": "^3.20.0", + "@tiptap/extension-list-item": "^3.20.0", + "@tiptap/extension-list-keymap": "^3.20.0", + "@tiptap/extension-ordered-list": "^3.20.0", + "@tiptap/extension-paragraph": "^3.20.0", + "@tiptap/extension-strike": "^3.20.0", + "@tiptap/extension-text": "^3.20.0", + "@tiptap/extension-underline": "^3.20.0", + "@tiptap/extensions": "^3.20.0", + "@tiptap/pm": "^3.20.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/dlv": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/dlv/-/dlv-1.1.5.tgz", + "integrity": "sha512-JHOWNfiWepAhfwlSw17kiWrWrk6od2dEQgHltJw9AS0JPFoLZJBge5+Dnil2NfdjAvJ/+vGSX60/BRW20PpUXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-auth": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.4.19.tgz", + "integrity": "sha512-3RlZJcA0+NH25wYD85vpIGwW9oSTuEmLIaGbT8zg41w/Pa2hVWHKedjoUHHJtnzkBXzDb+CShkLnSw7IThDdqQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.4.19", + "@better-auth/telemetry": "1.4.19", + "@better-auth/utils": "0.3.0", + "@better-fetch/fetch": "1.1.21", + "@noble/ciphers": "^2.0.0", + "@noble/hashes": "^2.0.0", + "better-call": "1.1.8", + "defu": "^6.1.4", + "jose": "^6.1.0", + "kysely": "^0.28.5", + "nanostores": "^1.0.1", + "zod": "^4.3.5" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": ">=0.41.0", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.1.8.tgz", + "integrity": "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.3.0", + "@better-fetch/fetch": "^1.1.4", + "rou3": "^0.7.10", + "set-cookie-parser": "^2.7.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cidr-regex": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/cidr-regex/-/cidr-regex-5.0.3.tgz", + "integrity": "sha512-zfPT2uurEroxXqefaL2L7/fT5ED2XTutC6UwFbSZfqSOk1vk5VFY6xa6/R6pBxB4Uc8MNPbRW5ykqutFG5P5ww==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", + "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^7.1.0", + "string-width": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone-regexp": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-3.0.0.tgz", + "integrity": "sha512-ujdnoq2Kxb8s3ItNBtnYeXdm07FcU0u8ARAT1lQ2YdMwQC+cdiXX8KoqMVuglztILivceTtp4ivqGSmEmhBUJw==", + "license": "MIT", + "dependencies": { + "is-regexp": "^3.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-hrtime": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/convert-hrtime/-/convert-hrtime-5.0.0.tgz", + "integrity": "sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/croner": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz", + "integrity": "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==", + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dev": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.3", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", + "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/function-timeout/-/function-timeout-0.1.1.tgz", + "integrity": "sha512-0NVVC0TaP7dSTvn1yMiy6d6Q8gifzbvQafO46RtLG/kHJUBNd+pVRGOBoK44wNBvtSPUJRfdVvkFdD3p0xvyZg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generate-password-browser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/generate-password-browser/-/generate-password-browser-1.1.0.tgz", + "integrity": "sha512-qsQve0rVbCqGqAfKgZwjxKUfI1d1nyd22dz+kE8gn1iw1LxGkR+Slsl79XXfm2wxuK27IkopTs5KXcOEQnhg0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "randombytes": "^2.0.5" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", + "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hono-rate-limiter": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/hono-rate-limiter/-/hono-rate-limiter-0.4.2.tgz", + "integrity": "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw==", + "license": "MIT", + "peerDependencies": { + "hono": "^4.1.1" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/i18next": { + "version": "25.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.13.tgz", + "integrity": "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-range-check": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ip-range-check/-/ip-range-check-0.2.0.tgz", + "integrity": "sha512-oaM3l/3gHbLlt/tCWLvt0mj1qUaI+STuRFnUvARGCujK9vvU61+2JsDpmkMzR4VsJhuFXWWgeKKVnwwoFfzCqw==", + "license": "MIT", + "dependencies": { + "ipaddr.js": "^1.0.1" + } + }, + "node_modules/ip-regex": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz", + "integrity": "sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-cidr": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/is-cidr/-/is-cidr-6.0.3.tgz", + "integrity": "sha512-tPdsizbDiISrc4PoII6ZfpmAokx0oDKeYqAUp5bXOfznauOFXfEeosKBRrl0o0SriE4xoRR05Czn4YPCFMjSHA==", + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^5.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-ip": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-5.0.1.tgz", + "integrity": "sha512-FCsGHdlrOnZQcp0+XT5a+pYowf33itBalCl+7ovNXC/7o5BhIpG14M3OrpPPdBSIQJCm+0M5+9mO7S9VVTTCFw==", + "license": "MIT", + "dependencies": { + "ip-regex": "^5.0.0", + "super-regex": "^0.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/isbot": { + "version": "5.1.35", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.35.tgz", + "integrity": "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kysely": { + "version": "0.28.11", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", + "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.2.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", + "integrity": "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.2", + "listr2": "^9.0.5", + "micromatch": "^4.0.8", + "nano-spawn": "^2.0.0", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.8.1" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/lucide-react": { + "version": "0.561.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", + "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", + "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nano-spawn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", + "integrity": "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" + } + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/nanostores": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.1.0.tgz", + "integrity": "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi3-ts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.5.0.tgz", + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", + "license": "MIT", + "dependencies": { + "yaml": "^2.8.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-organize-imports": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz", + "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": ">=2.0", + "typescript": ">=2.9", + "vue-tsc": "^2.1.0 || 3" + }, + "peerDependenciesMeta": { + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/prettier-plugin-prisma": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-prisma/-/prettier-plugin-prisma-5.0.0.tgz", + "integrity": "sha512-jTJV04D9+yF7ziOOMs7CJe4ijgAH7DEGjt0SAWAToGNRy1H6BEhvcKA2UQH6gC6KVW5zeeOSAvsoiDDTt9oKXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@prisma/prisma-schema-wasm": "4.17.0-26.6b0aef69b7cdfc787f822ecd7cdc76d5f1991584" + }, + "engines": { + "node": ">=14", + "npm": ">=8" + }, + "peerDependencies": { + "prettier": ">=2 || >=3" + } + }, + "node_modules/prisma": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.1.tgz", + "integrity": "sha512-gDKOXwnPiMdB+uYMhMeN8jj4K7Cu3Q2wB/wUsITOoOk446HtVb8T9BZxFJ1Zop6alc89k6PMNdR2FZCpbXp/jw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.4.1", + "@prisma/dev": "0.20.0", + "@prisma/engines": "7.4.1", + "@prisma/studio-core": "0.13.1", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.0.tgz", + "integrity": "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", + "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz", + "integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.0.tgz", + "integrity": "sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.4", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", + "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.11.0.tgz", + "integrity": "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", + "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-dropzone": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-i18next": { + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "dev": true, + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "license": "MIT" + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/super-regex": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz", + "integrity": "sha512-WZzIx3rC1CvbMDloLsVw0lkZVKJWbrkJ0k1ghKFmcnPrW1+jWbgTkTEWVtD9lMdmI4jZEz40+naBxl1dCUhXXw==", + "license": "MIT", + "dependencies": { + "clone-regexp": "^3.0.0", + "function-timeout": "^0.1.0", + "time-span": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/time-span": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/time-span/-/time-span-5.1.0.tgz", + "integrity": "sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==", + "license": "MIT", + "dependencies": { + "convert-hrtime": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dev": true, + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7e1600a --- /dev/null +++ b/package.json @@ -0,0 +1,122 @@ +{ + "name": "paste-es", + "version": "7.0.0", + "description": "Comparte secretos de forma segura con mensajes cifrados que se autodestruyen tras ser leídos.", + "type": "module", + "main": "dist/app.js", + "scripts": { + "build": "npx vite build", + "start": "npx prisma migrate deploy && npx tsx dist/app.js", + "dev:api": "npx prisma migrate dev && npx tsx --watch server.ts", + "dev:client": "npx vite", + "dev": "npm run dev:client", + "migrate:dev": "npx prisma migrate dev", + "migrate:deploy": "npx prisma migrate deploy", + "migrate:reset": "npx prisma migrate reset", + "migrate:status": "npx prisma migrate status", + "set:admin": "npx tsx scripts/admin.ts", + "seed:demo": "npx tsx scripts/seed-demo.ts", + "deploy": "npm run build && npm run migrate:deploy && npm run start", + "test": "hurl api/tests", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:debug": "playwright test --debug", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepare": "husky || true" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,css,md}": "prettier --write" + }, + "keywords": [ + "react", + "vite", + "hono", + "secrets", + "encryption", + "privacy", + "security" + ], + "dependencies": { + "@hono/node-server": "^1.19.7", + "@hono/swagger-ui": "^0.5.2", + "@hono/zod-openapi": "^1.1.5", + "@hono/zod-validator": "^0.7.5", + "@prisma/adapter-better-sqlite3": "^7.4.1", + "@prisma/client": "^7.4.1", + "@tailwindcss/vite": "^4.1.18", + "argon2": "^0.44.0", + "better-auth": "^1.4.6", + "better-sqlite3": "^12.5.0", + "croner": "^9.1.0", + "dlv": "^1.1.3", + "dotenv": "^17.2.3", + "hono": "^4.11.7", + "hono-rate-limiter": "^0.4.2", + "ip-address": "^10.1.0", + "ip-range-check": "^0.2.0", + "is-cidr": "^6.0.1", + "is-ip": "^5.0.1", + "isbot": "^5.1.32", + "nanoid": "^5.1.6", + "prisma": "^7.4.1", + "prom-client": "^15.1.3", + "react-markdown": "^10.1.0", + "ua-parser-js": "^2.0.7", + "validator": "^13.15.23", + "zod": "^4.1.13" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@hono/vite-dev-server": "^0.23.0", + "@playwright/test": "^1.57.0", + "@tabler/icons-react": "^3.35.0", + "@tailwindcss/typography": "^0.5.19", + "@tiptap/extension-character-count": "^3.13.0", + "@tiptap/extension-color": "^3.13.0", + "@tiptap/extension-link": "^3.13.0", + "@tiptap/extension-list-item": "^3.13.0", + "@tiptap/extension-text-style": "^3.13.0", + "@tiptap/react": "^3.13.0", + "@tiptap/starter-kit": "^3.13.0", + "@types/dlv": "^1.1.5", + "@types/node": "^25.0.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@types/validator": "^13.15.10", + "@vitejs/plugin-react": "^5.1.2", + "autoprefixer": "^10.4.22", + "clsx": "^2.1.1", + "eslint": "^9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "generate-password-browser": "^1.1.0", + "globals": "^16.5.0", + "husky": "^9.1.7", + "i18next": "^25.7.2", + "i18next-browser-languagedetector": "^8.2.0", + "lint-staged": "^16.2.7", + "lucide-react": "^0.561.0", + "postcss": "^8.5.6", + "prettier": "^3.7.4", + "prettier-plugin-organize-imports": "^4.3.0", + "prettier-plugin-prisma": "^5.0.0", + "qrcode.react": "^4.2.0", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-dropzone": "^14.3.8", + "react-i18next": "^16.5.0", + "react-router-dom": "^7.12.0", + "recharts": "^3.5.1", + "sonner": "^2.0.7", + "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.49.0", + "vite": "^7.2.7", + "zustand": "^5.0.9" + }, + "prisma": { + "schema": "prisma/schema.prisma" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a45b95d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['html'], ['list']], + globalSetup: './tests/e2e/global-setup.ts', + globalTeardown: './tests/e2e/global-teardown.ts', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'DATABASE_URL=file:./database/hemmelig-test.db npx vite', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 60000, + env: { + DATABASE_URL: 'file:./database/hemmelig-test.db', + }, + }, +}); diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..dd69f2f --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,12 @@ +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env.DATABASE_URL || 'file:./database/hemmelig.db', + }, +}); diff --git a/prisma/migrations/20250622202257_initial_v7_0_0/migration.sql b/prisma/migrations/20250622202257_initial_v7_0_0/migration.sql new file mode 100644 index 0000000..39de8df --- /dev/null +++ b/prisma/migrations/20250622202257_initial_v7_0_0/migration.sql @@ -0,0 +1,71 @@ +-- CreateTable +CREATE TABLE "secrets" ( + "id" BIGINT NOT NULL PRIMARY KEY, + "secret" TEXT NOT NULL, + "title" TEXT, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME, + "is_public" BOOLEAN DEFAULT false, + "ip_range" TEXT DEFAULT '' +); + +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL, + "image" TEXT, + "createdAt" DATETIME NOT NULL, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "expiresAt" DATETIME NOT NULL, + "token" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL, + "updatedAt" DATETIME NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "userId" TEXT NOT NULL, + CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "accountId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "accessToken" TEXT, + "refreshToken" TEXT, + "idToken" TEXT, + "accessTokenExpiresAt" DATETIME, + "refreshTokenExpiresAt" DATETIME, + "scope" TEXT, + "password" TEXT, + "createdAt" DATETIME NOT NULL, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "verification" ( + "id" TEXT NOT NULL PRIMARY KEY, + "identifier" TEXT NOT NULL, + "value" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME, + "updatedAt" DATETIME +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); diff --git a/prisma/migrations/20250623063739_change_fields/migration.sql b/prisma/migrations/20250623063739_change_fields/migration.sql new file mode 100644 index 0000000..39d6e82 --- /dev/null +++ b/prisma/migrations/20250623063739_change_fields/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - The primary key for the `secrets` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `is_public` on the `secrets` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" TEXT NOT NULL, + "title" TEXT, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME, + "ip_range" TEXT DEFAULT '' +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250626183203_add_username/migration.sql b/prisma/migrations/20250626183203_add_username/migration.sql new file mode 100644 index 0000000..d519271 --- /dev/null +++ b/prisma/migrations/20250626183203_add_username/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - Added the required column `username` to the `user` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_user" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL, + "image" TEXT, + "createdAt" DATETIME NOT NULL, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_user" ("createdAt", "email", "emailVerified", "id", "image", "name", "updatedAt") SELECT "createdAt", "email", "emailVerified", "id", "image", "name", "updatedAt" FROM "user"; +DROP TABLE "user"; +ALTER TABLE "new_user" RENAME TO "user"; +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250626192902_username/migration.sql b/prisma/migrations/20250626192902_username/migration.sql new file mode 100644 index 0000000..05d1be8 --- /dev/null +++ b/prisma/migrations/20250626192902_username/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "displayUsername" TEXT; diff --git a/prisma/migrations/20250629090604_to_number/migration.sql b/prisma/migrations/20250629090604_to_number/migration.sql new file mode 100644 index 0000000..c7ccd41 --- /dev/null +++ b/prisma/migrations/20250629090604_to_number/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to alter the column `expires_at` on the `secrets` table. The data in that column could be lost. The data in that column will be cast from `DateTime` to `Int`. + - Made the column `expires_at` on table `secrets` required. This step will fail if there are existing NULL values in that column. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" TEXT NOT NULL, + "title" TEXT, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" INTEGER NOT NULL, + "ip_range" TEXT DEFAULT '' +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250629093304_secret_and_title_to_binary/migration.sql b/prisma/migrations/20250629093304_secret_and_title_to_binary/migration.sql new file mode 100644 index 0000000..864f474 --- /dev/null +++ b/prisma/migrations/20250629093304_secret_and_title_to_binary/migration.sql @@ -0,0 +1,27 @@ +/* + Warnings: + + - You are about to alter the column `secret` on the `secrets` table. The data in that column could be lost. The data in that column will be cast from `String` to `Binary`. + - You are about to alter the column `title` on the `secrets` table. The data in that column could be lost. The data in that column will be cast from `String` to `Binary`. + - Made the column `title` on table `secrets` required. This step will fail if there are existing NULL values in that column. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" BLOB NOT NULL, + "title" BLOB NOT NULL, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" INTEGER NOT NULL, + "ip_range" TEXT DEFAULT '' +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250629184216_expire_at_date/migration.sql b/prisma/migrations/20250629184216_expire_at_date/migration.sql new file mode 100644 index 0000000..a1715ec --- /dev/null +++ b/prisma/migrations/20250629184216_expire_at_date/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to alter the column `expires_at` on the `secrets` table. The data in that column could be lost. The data in that column will be cast from `Int` to `DateTime`. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" BLOB NOT NULL, + "title" BLOB NOT NULL, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME NOT NULL, + "ip_range" TEXT DEFAULT '' +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250703203028_add_user_to_secret/migration.sql b/prisma/migrations/20250703203028_add_user_to_secret/migration.sql new file mode 100644 index 0000000..d26fbdb --- /dev/null +++ b/prisma/migrations/20250703203028_add_user_to_secret/migration.sql @@ -0,0 +1,21 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" BLOB NOT NULL, + "title" BLOB NOT NULL, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME NOT NULL, + "ip_range" TEXT DEFAULT '', + "userId" TEXT, + CONSTRAINT "secrets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250705203515_add_file_model/migration.sql b/prisma/migrations/20250705203515_add_file_model/migration.sql new file mode 100644 index 0000000..e83a1e3 --- /dev/null +++ b/prisma/migrations/20250705203515_add_file_model/migration.sql @@ -0,0 +1,31 @@ +-- CreateTable +CREATE TABLE "files" ( + "id" TEXT NOT NULL PRIMARY KEY, + "filename" TEXT NOT NULL, + "path" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" BLOB NOT NULL, + "title" BLOB NOT NULL, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME NOT NULL, + "ip_range" TEXT DEFAULT '', + "userId" TEXT, + "fileId" TEXT, + CONSTRAINT "secrets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "secrets_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "files" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "userId", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "userId", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250705204724_secret_files_many_to_many/migration.sql b/prisma/migrations/20250705204724_secret_files_many_to_many/migration.sql new file mode 100644 index 0000000..46e9705 --- /dev/null +++ b/prisma/migrations/20250705204724_secret_files_many_to_many/migration.sql @@ -0,0 +1,41 @@ +/* + Warnings: + + - You are about to drop the column `fileId` on the `secrets` table. All the data in the column will be lost. + +*/ +-- CreateTable +CREATE TABLE "_FileToSecrets" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + CONSTRAINT "_FileToSecrets_A_fkey" FOREIGN KEY ("A") REFERENCES "files" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "_FileToSecrets_B_fkey" FOREIGN KEY ("B") REFERENCES "secrets" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" BLOB NOT NULL, + "title" BLOB NOT NULL, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME NOT NULL, + "ip_range" TEXT DEFAULT '', + "userId" TEXT, + CONSTRAINT "secrets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "userId", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "userId", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "_FileToSecrets_AB_unique" ON "_FileToSecrets"("A", "B"); + +-- CreateIndex +CREATE INDEX "_FileToSecrets_B_index" ON "_FileToSecrets"("B"); diff --git a/prisma/migrations/20250706192848_add_user_role_and_ban_fields/migration.sql b/prisma/migrations/20250706192848_add_user_role_and_ban_fields/migration.sql new file mode 100644 index 0000000..4a3c8bb --- /dev/null +++ b/prisma/migrations/20250706192848_add_user_role_and_ban_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "banExpires" DATETIME; +ALTER TABLE "user" ADD COLUMN "banReason" TEXT; +ALTER TABLE "user" ADD COLUMN "banned" BOOLEAN DEFAULT false; +ALTER TABLE "user" ADD COLUMN "role" TEXT DEFAULT 'user'; diff --git a/prisma/migrations/20250707062129_add_instance_settings/migration.sql b/prisma/migrations/20250707062129_add_instance_settings/migration.sql new file mode 100644 index 0000000..0cddf45 --- /dev/null +++ b/prisma/migrations/20250707062129_add_instance_settings/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "instance_settings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "instanceName" TEXT DEFAULT 'Hemmelig Instance', + "instanceDescription" TEXT DEFAULT 'Secure secret sharing platform', + "allowRegistration" BOOLEAN DEFAULT true, + "requireEmailVerification" BOOLEAN DEFAULT false, + "maxSecretsPerUser" INTEGER DEFAULT 100, + "defaultSecretExpiration" INTEGER DEFAULT 72, + "maxSecretSize" INTEGER DEFAULT 1024, + "enforceHttps" BOOLEAN DEFAULT true, + "allowPasswordProtection" BOOLEAN DEFAULT true, + "allowIpRestriction" BOOLEAN DEFAULT true, + "maxPasswordAttempts" INTEGER DEFAULT 3, + "sessionTimeout" INTEGER DEFAULT 24, + "enableRateLimiting" BOOLEAN DEFAULT true, + "rateLimitRequests" INTEGER DEFAULT 100, + "rateLimitWindow" INTEGER DEFAULT 60, + "smtpHost" TEXT, + "smtpPort" INTEGER, + "smtpUsername" TEXT, + "smtpPassword" TEXT, + "smtpSecure" BOOLEAN DEFAULT true, + "fromEmail" TEXT, + "fromName" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); diff --git a/prisma/migrations/20250707203934_add_tracking_model/migration.sql b/prisma/migrations/20250707203934_add_tracking_model/migration.sql new file mode 100644 index 0000000..93d3ace --- /dev/null +++ b/prisma/migrations/20250707203934_add_tracking_model/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "tracking" ( + "id" TEXT NOT NULL PRIMARY KEY, + "eventType" TEXT NOT NULL, + "path" TEXT NOT NULL, + "userAgent" TEXT, + "ipAddress" TEXT, + "city" TEXT, + "country" TEXT, + "region" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/prisma/migrations/20250707211019_remove_tracking_model/migration.sql b/prisma/migrations/20250707211019_remove_tracking_model/migration.sql new file mode 100644 index 0000000..be6d63f --- /dev/null +++ b/prisma/migrations/20250707211019_remove_tracking_model/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the `tracking` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "tracking"; +PRAGMA foreign_keys=on; diff --git a/prisma/migrations/20250720173355_salt/migration.sql b/prisma/migrations/20250720173355_salt/migration.sql new file mode 100644 index 0000000..de53ce6 --- /dev/null +++ b/prisma/migrations/20250720173355_salt/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - Added the required column `salt` to the `secrets` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_secrets" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" BLOB NOT NULL, + "title" BLOB NOT NULL, + "views" INTEGER DEFAULT 1, + "password" TEXT, + "salt" TEXT NOT NULL, + "is_burnable" BOOLEAN DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME NOT NULL, + "ip_range" TEXT DEFAULT '', + "userId" TEXT, + CONSTRAINT "secrets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_secrets" ("created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "userId", "views") SELECT "created_at", "expires_at", "id", "ip_range", "is_burnable", "password", "secret", "title", "userId", "views" FROM "secrets"; +DROP TABLE "secrets"; +ALTER TABLE "new_secrets" RENAME TO "secrets"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251202061039_latest/migration.sql b/prisma/migrations/20251202061039_latest/migration.sql new file mode 100644 index 0000000..2b5fe29 --- /dev/null +++ b/prisma/migrations/20251202061039_latest/migration.sql @@ -0,0 +1,40 @@ +/* + Warnings: + + - You are about to drop the column `enforceHttps` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `maxPasswordAttempts` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `maxSecretsPerUser` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `sessionTimeout` on the `instance_settings` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_instance_settings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "instanceName" TEXT DEFAULT '', + "instanceDescription" TEXT DEFAULT '', + "allowRegistration" BOOLEAN DEFAULT true, + "requireEmailVerification" BOOLEAN DEFAULT false, + "defaultSecretExpiration" INTEGER DEFAULT 72, + "maxSecretSize" INTEGER DEFAULT 1024, + "allowPasswordProtection" BOOLEAN DEFAULT true, + "allowIpRestriction" BOOLEAN DEFAULT true, + "enableRateLimiting" BOOLEAN DEFAULT true, + "rateLimitRequests" INTEGER DEFAULT 100, + "rateLimitWindow" INTEGER DEFAULT 60, + "smtpHost" TEXT, + "smtpPort" INTEGER, + "smtpUsername" TEXT, + "smtpPassword" TEXT, + "smtpSecure" BOOLEAN DEFAULT true, + "fromEmail" TEXT, + "fromName" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_instance_settings" ("allowIpRestriction", "allowPasswordProtection", "allowRegistration", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "fromEmail", "fromName", "id", "instanceDescription", "instanceName", "maxSecretSize", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "smtpHost", "smtpPassword", "smtpPort", "smtpSecure", "smtpUsername", "updatedAt") SELECT "allowIpRestriction", "allowPasswordProtection", "allowRegistration", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "fromEmail", "fromName", "id", "instanceDescription", "instanceName", "maxSecretSize", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "smtpHost", "smtpPassword", "smtpPort", "smtpSecure", "smtpUsername", "updatedAt" FROM "instance_settings"; +DROP TABLE "instance_settings"; +ALTER TABLE "new_instance_settings" RENAME TO "instance_settings"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251202182146_remove_email_settings/migration.sql b/prisma/migrations/20251202182146_remove_email_settings/migration.sql new file mode 100644 index 0000000..a763736 --- /dev/null +++ b/prisma/migrations/20251202182146_remove_email_settings/migration.sql @@ -0,0 +1,36 @@ +/* + Warnings: + + - You are about to drop the column `fromEmail` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `fromName` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `smtpHost` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `smtpPassword` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `smtpPort` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `smtpSecure` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `smtpUsername` on the `instance_settings` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_instance_settings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "instanceName" TEXT DEFAULT '', + "instanceDescription" TEXT DEFAULT '', + "allowRegistration" BOOLEAN DEFAULT true, + "requireEmailVerification" BOOLEAN DEFAULT false, + "defaultSecretExpiration" INTEGER DEFAULT 72, + "maxSecretSize" INTEGER DEFAULT 1024, + "allowPasswordProtection" BOOLEAN DEFAULT true, + "allowIpRestriction" BOOLEAN DEFAULT true, + "enableRateLimiting" BOOLEAN DEFAULT true, + "rateLimitRequests" INTEGER DEFAULT 100, + "rateLimitWindow" INTEGER DEFAULT 60, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_instance_settings" ("allowIpRestriction", "allowPasswordProtection", "allowRegistration", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "id", "instanceDescription", "instanceName", "maxSecretSize", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "updatedAt") SELECT "allowIpRestriction", "allowPasswordProtection", "allowRegistration", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "id", "instanceDescription", "instanceName", "maxSecretSize", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "updatedAt" FROM "instance_settings"; +DROP TABLE "instance_settings"; +ALTER TABLE "new_instance_settings" RENAME TO "instance_settings"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251202184727_add_visitor_analytics/migration.sql b/prisma/migrations/20251202184727_add_visitor_analytics/migration.sql new file mode 100644 index 0000000..98222c1 --- /dev/null +++ b/prisma/migrations/20251202184727_add_visitor_analytics/migration.sql @@ -0,0 +1,7 @@ +-- CreateTable +CREATE TABLE "visitor_analytics" ( + "id" TEXT NOT NULL PRIMARY KEY, + "path" TEXT NOT NULL, + "uniqueId" TEXT NOT NULL, + "timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/prisma/migrations/20251202190532_add_organization_features/migration.sql b/prisma/migrations/20251202190532_add_organization_features/migration.sql new file mode 100644 index 0000000..d8891f6 --- /dev/null +++ b/prisma/migrations/20251202190532_add_organization_features/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "instance_settings" ADD COLUMN "logoUrl" TEXT DEFAULT ''; +ALTER TABLE "instance_settings" ADD COLUMN "primaryColor" TEXT DEFAULT '#14b8a6'; +ALTER TABLE "instance_settings" ADD COLUMN "requireApproval" BOOLEAN DEFAULT false; +ALTER TABLE "instance_settings" ADD COLUMN "requireInviteCode" BOOLEAN DEFAULT false; + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "approved" BOOLEAN DEFAULT true; +ALTER TABLE "user" ADD COLUMN "inviteCodeUsed" TEXT; + +-- CreateTable +CREATE TABLE "invite_codes" ( + "id" TEXT NOT NULL PRIMARY KEY, + "code" TEXT NOT NULL, + "uses" INTEGER NOT NULL DEFAULT 0, + "maxUses" INTEGER DEFAULT 1, + "expiresAt" DATETIME, + "createdBy" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "isActive" BOOLEAN NOT NULL DEFAULT true +); + +-- CreateIndex +CREATE UNIQUE INDEX "invite_codes_code_key" ON "invite_codes"("code"); diff --git a/prisma/migrations/20251202192446_remove_require_approval/migration.sql b/prisma/migrations/20251202192446_remove_require_approval/migration.sql new file mode 100644 index 0000000..1cd1507 --- /dev/null +++ b/prisma/migrations/20251202192446_remove_require_approval/migration.sql @@ -0,0 +1,54 @@ +/* + Warnings: + + - You are about to drop the column `requireApproval` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `approved` on the `user` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_instance_settings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "instanceName" TEXT DEFAULT '', + "instanceDescription" TEXT DEFAULT '', + "allowRegistration" BOOLEAN DEFAULT true, + "requireEmailVerification" BOOLEAN DEFAULT false, + "defaultSecretExpiration" INTEGER DEFAULT 72, + "maxSecretSize" INTEGER DEFAULT 1024, + "allowPasswordProtection" BOOLEAN DEFAULT true, + "allowIpRestriction" BOOLEAN DEFAULT true, + "enableRateLimiting" BOOLEAN DEFAULT true, + "rateLimitRequests" INTEGER DEFAULT 100, + "rateLimitWindow" INTEGER DEFAULT 60, + "requireInviteCode" BOOLEAN DEFAULT false, + "logoUrl" TEXT DEFAULT '', + "primaryColor" TEXT DEFAULT '#14b8a6', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_instance_settings" ("allowIpRestriction", "allowPasswordProtection", "allowRegistration", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "id", "instanceDescription", "instanceName", "logoUrl", "maxSecretSize", "primaryColor", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "requireInviteCode", "updatedAt") SELECT "allowIpRestriction", "allowPasswordProtection", "allowRegistration", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "id", "instanceDescription", "instanceName", "logoUrl", "maxSecretSize", "primaryColor", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "requireInviteCode", "updatedAt" FROM "instance_settings"; +DROP TABLE "instance_settings"; +ALTER TABLE "new_instance_settings" RENAME TO "instance_settings"; +CREATE TABLE "new_user" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL, + "image" TEXT, + "createdAt" DATETIME NOT NULL, + "updatedAt" DATETIME NOT NULL, + "displayUsername" TEXT, + "role" TEXT DEFAULT 'user', + "banned" BOOLEAN DEFAULT false, + "banReason" TEXT, + "banExpires" DATETIME, + "inviteCodeUsed" TEXT +); +INSERT INTO "new_user" ("banExpires", "banReason", "banned", "createdAt", "displayUsername", "email", "emailVerified", "id", "image", "inviteCodeUsed", "name", "role", "updatedAt", "username") SELECT "banExpires", "banReason", "banned", "createdAt", "displayUsername", "email", "emailVerified", "id", "image", "inviteCodeUsed", "name", "role", "updatedAt", "username" FROM "user"; +DROP TABLE "user"; +ALTER TABLE "new_user" RENAME TO "user"; +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251203150000_add_organization_fields/migration.sql b/prisma/migrations/20251203150000_add_organization_fields/migration.sql new file mode 100644 index 0000000..6e2a8be --- /dev/null +++ b/prisma/migrations/20251203150000_add_organization_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable - Add organization fields +ALTER TABLE "instance_settings" ADD COLUMN "allowedEmailDomains" TEXT DEFAULT ''; +ALTER TABLE "instance_settings" ADD COLUMN "requireRegisteredUser" BOOLEAN DEFAULT false; diff --git a/prisma/migrations/20251203150440_new/migration.sql b/prisma/migrations/20251203150440_new/migration.sql new file mode 100644 index 0000000..c3f35f6 --- /dev/null +++ b/prisma/migrations/20251203150440_new/migration.sql @@ -0,0 +1,34 @@ +/* + Warnings: + + - You are about to drop the column `logoUrl` on the `instance_settings` table. All the data in the column will be lost. + - You are about to drop the column `primaryColor` on the `instance_settings` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_instance_settings" ( + "id" TEXT NOT NULL PRIMARY KEY, + "instanceName" TEXT DEFAULT '', + "instanceDescription" TEXT DEFAULT '', + "allowRegistration" BOOLEAN DEFAULT true, + "requireEmailVerification" BOOLEAN DEFAULT false, + "defaultSecretExpiration" INTEGER DEFAULT 72, + "maxSecretSize" INTEGER DEFAULT 1024, + "allowPasswordProtection" BOOLEAN DEFAULT true, + "allowIpRestriction" BOOLEAN DEFAULT true, + "enableRateLimiting" BOOLEAN DEFAULT true, + "rateLimitRequests" INTEGER DEFAULT 100, + "rateLimitWindow" INTEGER DEFAULT 60, + "requireInviteCode" BOOLEAN DEFAULT false, + "allowedEmailDomains" TEXT DEFAULT '', + "requireRegisteredUser" BOOLEAN DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_instance_settings" ("allowIpRestriction", "allowPasswordProtection", "allowRegistration", "allowedEmailDomains", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "id", "instanceDescription", "instanceName", "maxSecretSize", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "requireInviteCode", "requireRegisteredUser", "updatedAt") SELECT "allowIpRestriction", "allowPasswordProtection", "allowRegistration", "allowedEmailDomains", "createdAt", "defaultSecretExpiration", "enableRateLimiting", "id", "instanceDescription", "instanceName", "maxSecretSize", "rateLimitRequests", "rateLimitWindow", "requireEmailVerification", "requireInviteCode", "requireRegisteredUser", "updatedAt" FROM "instance_settings"; +DROP TABLE "instance_settings"; +ALTER TABLE "new_instance_settings" RENAME TO "instance_settings"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251203210527_add_unique_username/migration.sql b/prisma/migrations/20251203210527_add_unique_username/migration.sql new file mode 100644 index 0000000..f2b10fc --- /dev/null +++ b/prisma/migrations/20251203210527_add_unique_username/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[username]` on the table `user` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "user_username_key" ON "user"("username"); diff --git a/prisma/migrations/20251203212204_add_webhook_notifications/migration.sql b/prisma/migrations/20251203212204_add_webhook_notifications/migration.sql new file mode 100644 index 0000000..c826662 --- /dev/null +++ b/prisma/migrations/20251203212204_add_webhook_notifications/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "instance_settings" ADD COLUMN "webhookEnabled" BOOLEAN DEFAULT false; +ALTER TABLE "instance_settings" ADD COLUMN "webhookOnBurn" BOOLEAN DEFAULT true; +ALTER TABLE "instance_settings" ADD COLUMN "webhookOnView" BOOLEAN DEFAULT true; +ALTER TABLE "instance_settings" ADD COLUMN "webhookSecret" TEXT DEFAULT ''; +ALTER TABLE "instance_settings" ADD COLUMN "webhookUrl" TEXT DEFAULT ''; diff --git a/prisma/migrations/20251204171251_add_api_keys/migration.sql b/prisma/migrations/20251204171251_add_api_keys/migration.sql new file mode 100644 index 0000000..3e0a793 --- /dev/null +++ b/prisma/migrations/20251204171251_add_api_keys/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "api_keys" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "key_hash" TEXT NOT NULL, + "key_prefix" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "last_used_at" DATETIME, + "expires_at" DATETIME, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "api_keys_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "api_keys_key_hash_key" ON "api_keys"("key_hash"); diff --git a/prisma/migrations/20251204204518_add_two_factor_authentication/migration.sql b/prisma/migrations/20251204204518_add_two_factor_authentication/migration.sql new file mode 100644 index 0000000..11481ee --- /dev/null +++ b/prisma/migrations/20251204204518_add_two_factor_authentication/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "user" ADD COLUMN "twoFactorBackupCodes" TEXT; +ALTER TABLE "user" ADD COLUMN "twoFactorEnabled" BOOLEAN DEFAULT false; +ALTER TABLE "user" ADD COLUMN "twoFactorSecret" TEXT; diff --git a/prisma/migrations/20251204205345_fix_two_factor_schema/migration.sql b/prisma/migrations/20251204205345_fix_two_factor_schema/migration.sql new file mode 100644 index 0000000..ecf2cf6 --- /dev/null +++ b/prisma/migrations/20251204205345_fix_two_factor_schema/migration.sql @@ -0,0 +1,43 @@ +/* + Warnings: + + - You are about to drop the column `twoFactorBackupCodes` on the `user` table. All the data in the column will be lost. + - You are about to drop the column `twoFactorSecret` on the `user` table. All the data in the column will be lost. + +*/ +-- CreateTable +CREATE TABLE "twoFactor" ( + "id" TEXT NOT NULL PRIMARY KEY, + "secret" TEXT NOT NULL, + "backupCodes" TEXT NOT NULL, + "userId" TEXT NOT NULL, + CONSTRAINT "twoFactor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_user" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" BOOLEAN NOT NULL, + "image" TEXT, + "createdAt" DATETIME NOT NULL, + "updatedAt" DATETIME NOT NULL, + "displayUsername" TEXT, + "role" TEXT DEFAULT 'user', + "banned" BOOLEAN DEFAULT false, + "banReason" TEXT, + "banExpires" DATETIME, + "inviteCodeUsed" TEXT, + "twoFactorEnabled" BOOLEAN DEFAULT false +); +INSERT INTO "new_user" ("banExpires", "banReason", "banned", "createdAt", "displayUsername", "email", "emailVerified", "id", "image", "inviteCodeUsed", "name", "role", "twoFactorEnabled", "updatedAt", "username") SELECT "banExpires", "banReason", "banned", "createdAt", "displayUsername", "email", "emailVerified", "id", "image", "inviteCodeUsed", "name", "role", "twoFactorEnabled", "updatedAt", "username" FROM "user"; +DROP TABLE "user"; +ALTER TABLE "new_user" RENAME TO "user"; +CREATE UNIQUE INDEX "user_username_key" ON "user"("username"); +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251205130939_add_important_message/migration.sql b/prisma/migrations/20251205130939_add_important_message/migration.sql new file mode 100644 index 0000000..cb216c3 --- /dev/null +++ b/prisma/migrations/20251205130939_add_important_message/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "instance_settings" ADD COLUMN "importantMessage" TEXT DEFAULT ''; diff --git a/prisma/migrations/20251212205031_new/migration.sql b/prisma/migrations/20251212205031_new/migration.sql new file mode 100644 index 0000000..4bb7c96 --- /dev/null +++ b/prisma/migrations/20251212205031_new/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "visitor_analytics_timestamp_idx" ON "visitor_analytics"("timestamp"); diff --git a/prisma/migrations/20251213160602_add_metrics_settings/migration.sql b/prisma/migrations/20251213160602_add_metrics_settings/migration.sql new file mode 100644 index 0000000..5acda2a --- /dev/null +++ b/prisma/migrations/20251213160602_add_metrics_settings/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "instance_settings" ADD COLUMN "metricsEnabled" BOOLEAN DEFAULT false; +ALTER TABLE "instance_settings" ADD COLUMN "metricsSecret" TEXT DEFAULT ''; diff --git a/prisma/migrations/20251214110935_secret_request/migration.sql b/prisma/migrations/20251214110935_secret_request/migration.sql new file mode 100644 index 0000000..eaa65e8 --- /dev/null +++ b/prisma/migrations/20251214110935_secret_request/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "secret_requests" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT, + "max_views" INTEGER NOT NULL DEFAULT 1, + "expires_in" INTEGER NOT NULL, + "password" TEXT, + "allowed_ip" TEXT, + "prevent_burn" BOOLEAN NOT NULL DEFAULT false, + "token" TEXT NOT NULL, + "webhook_url" TEXT, + "webhook_secret" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "user_id" TEXT NOT NULL, + "secret_id" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" DATETIME NOT NULL, + "fulfilled_at" DATETIME, + CONSTRAINT "secret_requests_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "secret_requests_secret_id_fkey" FOREIGN KEY ("secret_id") REFERENCES "secrets" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "secret_requests_token_key" ON "secret_requests"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "secret_requests_secret_id_key" ON "secret_requests"("secret_id"); + +-- CreateIndex +CREATE INDEX "secret_requests_user_id_idx" ON "secret_requests"("user_id"); + +-- CreateIndex +CREATE INDEX "secret_requests_token_idx" ON "secret_requests"("token"); + +-- CreateIndex +CREATE INDEX "secret_requests_status_idx" ON "secret_requests"("status"); diff --git a/prisma/migrations/20251222124844_disable_file_upload/migration.sql b/prisma/migrations/20251222124844_disable_file_upload/migration.sql new file mode 100644 index 0000000..bb8ada0 --- /dev/null +++ b/prisma/migrations/20251222124844_disable_file_upload/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "instance_settings" ADD COLUMN "allowFileUploads" BOOLEAN DEFAULT true; + +-- CreateIndex +CREATE INDEX "secrets_expires_at_idx" ON "secrets"("expires_at"); + +-- CreateIndex +CREATE INDEX "secrets_userId_idx" ON "secrets"("userId"); + +-- CreateIndex +CREATE INDEX "visitor_analytics_uniqueId_idx" ON "visitor_analytics"("uniqueId"); diff --git a/prisma/migrations/20251222130030_add_disable_email_password_signup/migration.sql b/prisma/migrations/20251222130030_add_disable_email_password_signup/migration.sql new file mode 100644 index 0000000..97e76fb --- /dev/null +++ b/prisma/migrations/20251222130030_add_disable_email_password_signup/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "instance_settings" ADD COLUMN "disableEmailPasswordSignup" BOOLEAN DEFAULT false; diff --git a/prisma/migrations/20251228172753_add_instance_logo/migration.sql b/prisma/migrations/20251228172753_add_instance_logo/migration.sql new file mode 100644 index 0000000..dd80169 --- /dev/null +++ b/prisma/migrations/20251228172753_add_instance_logo/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "instance_settings" ADD COLUMN "instanceLogo" TEXT DEFAULT ''; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..55b5447 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,237 @@ +datasource db { + provider = "sqlite" +} + +generator client { + provider = "prisma-client" + output = "./generated/prisma" +} + +model Secrets { + id String @id @default(uuid()) + secret Bytes + title Bytes + views Int? @default(1) + password String? + salt String + isBurnable Boolean? @default(false) @map("is_burnable") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + ipRange String? @default("") @map("ip_range") + userId String? + user User? @relation(fields: [userId], references: [id]) + files File[] @relation + secretRequest SecretRequest? + + @@index([expiresAt]) + @@index([userId]) + @@map("secrets") +} + +model File { + id String @id @default(uuid()) + filename String + path String + createdAt DateTime @default(now()) + secrets Secrets[] @relation + + @@map("files") +} + +model User { + id String @id + name String + username String @unique + email String + emailVerified Boolean + image String? + createdAt DateTime + updatedAt DateTime + sessions Session[] + accounts Account[] + + displayUsername String? + role String? @default("user") + banned Boolean? @default(false) + banReason String? + banExpires DateTime? + inviteCodeUsed String? + twoFactorEnabled Boolean? @default(false) + Secrets Secrets[] + apiKeys ApiKey[] + twoFactor TwoFactor[] + secretRequests SecretRequest[] + + @@unique([email]) + @@map("user") +} + +model TwoFactor { + id String @id @default(uuid()) + secret String + backupCodes String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("twoFactor") +} + +model Session { + id String @id + expiresAt DateTime + token String + createdAt DateTime + updatedAt DateTime + ipAddress String? + userAgent String? + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([token]) + @@map("session") +} + +model Account { + id String @id + accountId String + providerId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? + idToken String? + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + password String? + createdAt DateTime + updatedAt DateTime + + @@map("account") +} + +model Verification { + id String @id + identifier String + value String + expiresAt DateTime + createdAt DateTime? + updatedAt DateTime? + + @@map("verification") +} + +model InstanceSettings { + id String @id @default(uuid()) + instanceName String? @default("") + instanceDescription String? @default("") + instanceLogo String? @default("") // Base64 encoded logo image + allowRegistration Boolean? @default(true) + requireEmailVerification Boolean? @default(false) + defaultSecretExpiration Int? @default(72) // hours + maxSecretSize Int? @default(1024) // KB + allowPasswordProtection Boolean? @default(true) + allowIpRestriction Boolean? @default(true) + enableRateLimiting Boolean? @default(true) + rateLimitRequests Int? @default(100) + rateLimitWindow Int? @default(60) // minutes + // Organization features + requireInviteCode Boolean? @default(false) + allowedEmailDomains String? @default("") // comma-separated list of allowed email domains + requireRegisteredUser Boolean? @default(false) // only registered users can create secrets + disableEmailPasswordSignup Boolean? @default(false) // disable email/password registration (social login only) + // Webhook notifications + webhookEnabled Boolean? @default(false) + webhookUrl String? @default("") + webhookSecret String? @default("") // HMAC secret for signing webhook payloads + webhookOnView Boolean? @default(true) // send webhook when secret is viewed + webhookOnBurn Boolean? @default(true) // send webhook when secret is burned/deleted + // Important message alert + importantMessage String? @default("") // Message to display to all users + // Prometheus metrics + metricsEnabled Boolean? @default(false) + metricsSecret String? @default("") // Bearer token for /metrics endpoint + // File uploads + allowFileUploads Boolean? @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("instance_settings") +} + +model InviteCode { + id String @id @default(uuid()) + code String @unique + uses Int @default(0) + maxUses Int? @default(1) + expiresAt DateTime? + createdBy String + createdAt DateTime @default(now()) + isActive Boolean @default(true) + + @@map("invite_codes") +} + +model VisitorAnalytics { + id String @id @default(uuid()) + path String + uniqueId String + timestamp DateTime @default(now()) + + @@index([timestamp]) + @@index([uniqueId]) + @@map("visitor_analytics") +} + +model ApiKey { + id String @id @default(uuid()) + name String + keyHash String @unique @map("key_hash") + keyPrefix String @map("key_prefix") + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + @@map("api_keys") +} + +model SecretRequest { + id String @id @default(uuid()) + title String // Displayed to Creator + description String? // Optional additional context + + // Pre-configured secret settings + maxViews Int @default(1) @map("max_views") + expiresIn Int @map("expires_in") // Seconds until secret expires after creation + password String? // Optional password protection (hashed) + allowedIp String? @map("allowed_ip") // Optional IP restriction + preventBurn Boolean @default(false) @map("prevent_burn") + + // Request security + token String @unique // Secure token for Creator Link + + // Webhook configuration + webhookUrl String? @map("webhook_url") // Optional webhook URL + webhookSecret String? @map("webhook_secret") // HMAC secret for webhook signature + + // Status tracking + status String @default("pending") // pending | fulfilled | expired | cancelled + + // Relationships + userId String @map("user_id") // Requester's user ID + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + secretId String? @unique @map("secret_id") // Created secret (once fulfilled) + secret Secrets? @relation(fields: [secretId], references: [id], onDelete: SetNull) + + // Timestamps + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") // When the Creator Link expires + fulfilledAt DateTime? @map("fulfilled_at") // When secret was created + + @@index([userId]) + @@index([token]) + @@index([status]) + @@map("secret_requests") +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..f328adc Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icons/android-chrome-192x192.png b/public/icons/android-chrome-192x192.png new file mode 100644 index 0000000..f0b7f12 Binary files /dev/null and b/public/icons/android-chrome-192x192.png differ diff --git a/public/icons/android-chrome-512x512.png b/public/icons/android-chrome-512x512.png new file mode 100644 index 0000000..d73d841 Binary files /dev/null and b/public/icons/android-chrome-512x512.png differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..e858a2b Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/favicon-16x16.png b/public/icons/favicon-16x16.png new file mode 100644 index 0000000..4d4cab9 Binary files /dev/null and b/public/icons/favicon-16x16.png differ diff --git a/public/icons/favicon-32x32.png b/public/icons/favicon-32x32.png new file mode 100644 index 0000000..a2495cb Binary files /dev/null and b/public/icons/favicon-32x32.png differ diff --git a/public/icons/icon-128x128.png b/public/icons/icon-128x128.png new file mode 100644 index 0000000..d4fbd5f Binary files /dev/null and b/public/icons/icon-128x128.png differ diff --git a/public/icons/icon-144x144.png b/public/icons/icon-144x144.png new file mode 100644 index 0000000..5db29a4 Binary files /dev/null and b/public/icons/icon-144x144.png differ diff --git a/public/icons/icon-152x152.png b/public/icons/icon-152x152.png new file mode 100644 index 0000000..1304731 Binary files /dev/null and b/public/icons/icon-152x152.png differ diff --git a/public/icons/icon-192x192.png b/public/icons/icon-192x192.png new file mode 100644 index 0000000..d7f56ca Binary files /dev/null and b/public/icons/icon-192x192.png differ diff --git a/public/icons/icon-384x384.png b/public/icons/icon-384x384.png new file mode 100644 index 0000000..65cf0af Binary files /dev/null and b/public/icons/icon-384x384.png differ diff --git a/public/icons/icon-512x512.png b/public/icons/icon-512x512.png new file mode 100644 index 0000000..7fad691 Binary files /dev/null and b/public/icons/icon-512x512.png differ diff --git a/public/icons/icon-72x72.png b/public/icons/icon-72x72.png new file mode 100644 index 0000000..d8c41ad Binary files /dev/null and b/public/icons/icon-72x72.png differ diff --git a/public/icons/icon-96x96.png b/public/icons/icon-96x96.png new file mode 100644 index 0000000..b520d65 Binary files /dev/null and b/public/icons/icon-96x96.png differ diff --git a/public/icons/maskable-icon-192x192.png b/public/icons/maskable-icon-192x192.png new file mode 100644 index 0000000..b4f9dd9 Binary files /dev/null and b/public/icons/maskable-icon-192x192.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..d7e7880 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,58 @@ +{ + "name": "paste.es", + "short_name": "paste.es", + "theme_color": "#deefea", + "background_color": "#231e23", + "display": "standalone", + "orientation": "portrait", + "scope": "/", + "start_url": "/", + "icons": [ + { + "src": "icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png" + }, + { + "src": "icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png" + }, + { + "src": "icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/maskable-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..5537f07 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: \ No newline at end of file diff --git a/scripts/admin.ts b/scripts/admin.ts new file mode 100644 index 0000000..0d2a7a2 --- /dev/null +++ b/scripts/admin.ts @@ -0,0 +1,35 @@ +import db from '../api/lib/db'; + +async function main() { + const email = process.argv[2]; + + if (!email) { + console.error('Please provide an email address.'); + process.exit(1); + } + + try { + const user = await db.user.findUnique({ + where: { email }, + }); + + if (!user) { + console.error(`User with email "${email}" not found.`); + process.exit(1); + } + + await db.user.update({ + where: { email }, + data: { role: 'admin' }, + }); + + console.log(`User "${email}" has been granted admin privileges.`); + } catch (error) { + console.error('An error occurred:', error); + process.exit(1); + } finally { + await db.$disconnect(); + } +} + +main(); diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..cbbda96 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Fix permissions on mounted volumes (runs as root) +chown -R app:app /app/database /app/uploads 2>/dev/null || true + +# Run migrations and start app as app user +exec gosu app sh -c 'npx prisma migrate deploy && exec npx tsx server.ts' diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts new file mode 100644 index 0000000..f9d5c93 --- /dev/null +++ b/scripts/seed-demo.ts @@ -0,0 +1,205 @@ +import crypto from 'crypto'; +import db from '../api/lib/db'; + +const DAYS_TO_GENERATE = 30; +const VISITOR_PATHS = ['/', '/secret']; + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function randomDate(daysAgo: number): Date { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + date.setHours(randomInt(0, 23), randomInt(0, 59), randomInt(0, 59)); + return date; +} + +function generateUniqueVisitorId(): string { + return crypto.randomBytes(16).toString('hex'); +} + +// ============================================ +// Instance Settings +// ============================================ +async function seedInstanceSettings() { + console.log('Seeding instance settings...'); + + const existing = await db.instanceSettings.findFirst(); + if (existing) { + console.log(' Instance settings already exist, skipping...'); + console.log(' Done!\n'); + return existing; + } + + const settings = await db.instanceSettings.create({ + data: { + instanceName: 'Hemmelig Demo', + instanceDescription: 'A demo instance of Hemmelig for testing and development.', + allowRegistration: true, + requireEmailVerification: false, + defaultSecretExpiration: 72, + maxSecretSize: 1024, + allowPasswordProtection: true, + allowIpRestriction: true, + enableRateLimiting: true, + rateLimitRequests: 100, + rateLimitWindow: 60, + requireInviteCode: false, + webhookEnabled: false, + }, + }); + + console.log(' Created instance settings'); + console.log(' Done!\n'); + return settings; +} + +// ============================================ +// Secrets +// ============================================ +async function seedSecrets() { + console.log('Seeding secrets...'); + + const records = []; + + for (let daysAgo = 0; daysAgo < DAYS_TO_GENERATE; daysAgo++) { + // Generate between 1-15 secrets per day + const secretsCount = randomInt(1, 15); + + for (let i = 0; i < secretsCount; i++) { + const createdAt = randomDate(daysAgo); + + // Random expiration: 1 hour, 1 day, 1 week, or more + const expirationHours = [1, 24, 168, 336, 672][randomInt(0, 4)]; + const expiresAt = new Date(createdAt.getTime() + expirationHours * 60 * 60 * 1000); + + // Random features + const hasPassword = Math.random() < 0.3; + const hasIpRange = Math.random() < 0.1; + const isBurnable = Math.random() < 0.4; + + // Random views + const views = randomInt(1, 20); + + // Generate dummy encrypted data + const secret = Buffer.from(crypto.randomBytes(32)); + const title = Buffer.from(crypto.randomBytes(16)); + const salt = crypto.randomBytes(16).toString('hex'); + + records.push({ + secret, + title, + salt, + views, + password: hasPassword ? crypto.randomBytes(32).toString('hex') : null, + ipRange: hasIpRange ? '192.168.1.0/24' : '', + isBurnable, + createdAt, + expiresAt, + }); + } + } + + // Batch insert + const batchSize = 100; + for (let i = 0; i < records.length; i += batchSize) { + const batch = records.slice(i, i + batchSize); + await db.secrets.createMany({ data: batch }); + process.stdout.write( + `\r Created ${Math.min(i + batchSize, records.length)}/${records.length} secrets` + ); + } + + console.log('\n Done!\n'); +} + +// ============================================ +// Visitor Analytics +// ============================================ +async function seedVisitorAnalytics() { + console.log('Seeding visitor analytics...'); + + const records = []; + + for (let daysAgo = 0; daysAgo < DAYS_TO_GENERATE; daysAgo++) { + // Generate between 5-50 unique visitors per day + const uniqueVisitors = randomInt(5, 50); + const visitorIds = Array.from({ length: uniqueVisitors }, () => generateUniqueVisitorId()); + + for (const visitorId of visitorIds) { + // Each visitor views 1-5 pages + const pageViews = randomInt(1, 5); + + for (let i = 0; i < pageViews; i++) { + const path = VISITOR_PATHS[randomInt(0, VISITOR_PATHS.length - 1)]; + records.push({ + path, + uniqueId: visitorId, + timestamp: randomDate(daysAgo), + }); + } + } + } + + // Batch insert + const batchSize = 500; + for (let i = 0; i < records.length; i += batchSize) { + const batch = records.slice(i, i + batchSize); + await db.visitorAnalytics.createMany({ data: batch }); + process.stdout.write( + `\r Created ${Math.min(i + batchSize, records.length)}/${records.length} visitor records` + ); + } + + console.log('\n Done!\n'); +} + +// ============================================ +// Clear Data +// ============================================ +async function clearDemoData() { + console.log('Clearing existing demo data...'); + + await db.visitorAnalytics.deleteMany({}); + console.log(' Cleared visitor analytics'); + + // Only delete anonymous secrets (no userId) + await db.secrets.deleteMany({ where: { userId: null } }); + console.log(' Cleared anonymous secrets'); + + await db.instanceSettings.deleteMany({}); + console.log(' Cleared instance settings'); + + console.log(' Done!\n'); +} + +// ============================================ +// Main +// ============================================ +async function main() { + const args = process.argv.slice(2); + const shouldClear = args.includes('--clear'); + + console.log('\n🌱 Hemmelig Demo Database Seeder\n'); + console.log('This will populate the database with demo data for development.\n'); + + try { + if (shouldClear) { + await clearDemoData(); + } + + await seedInstanceSettings(); + await seedSecrets(); + await seedVisitorAnalytics(); + + console.log('✅ Demo database seeded successfully!\n'); + } catch (error) { + console.error('\n❌ An error occurred:', error); + process.exit(1); + } finally { + await db.$disconnect(); + } +} + +main(); diff --git a/scripts/update-packages.sh b/scripts/update-packages.sh new file mode 100755 index 0000000..05c5730 --- /dev/null +++ b/scripts/update-packages.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Script to update all packages in package.json to their latest versions + +PACKAGE_JSON="package.json" + +if [ ! -f "$PACKAGE_JSON" ]; then + echo "Error: package.json not found" + exit 1 +fi + +update_packages() { + local section=$1 + echo "" + echo "=== Updating $section ===" + echo "" + + # Extract package names from the section + packages=$(jq -r ".$section // {} | keys[]" "$PACKAGE_JSON" 2>/dev/null) + + if [ -z "$packages" ]; then + echo "No packages found in $section" + return + fi + + for package in $packages; do + current=$(jq -r ".$section[\"$package\"]" "$PACKAGE_JSON") + + # Get latest version from npm + latest=$(npm view "$package" version 2>/dev/null) + + if [ -z "$latest" ]; then + echo " ⚠ $package: Could not fetch latest version" + continue + fi + + # Compare versions (strip ^ or ~ from current) + current_clean=$(echo "$current" | sed 's/^[\^~]//') + + if [ "$current_clean" = "$latest" ]; then + echo " ✓ $package: $current (up to date)" + else + echo " ↑ $package: $current → ^$latest" + # Update package.json using jq + tmp=$(mktemp) + jq ".$section[\"$package\"] = \"^$latest\"" "$PACKAGE_JSON" > "$tmp" && mv "$tmp" "$PACKAGE_JSON" + fi + done +} + +echo "Checking for package updates..." + +update_packages "dependencies" +update_packages "devDependencies" + +echo "" +echo "=== Done ===" +echo "" +echo "Run 'npm install' to install updated packages" diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..f307c43 --- /dev/null +++ b/server.ts @@ -0,0 +1,55 @@ +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import 'dotenv/config'; +import { Hono } from 'hono'; +import api from './api/app'; +import config from './api/config'; + +const port = config.get('server.port')!; + +const app = new Hono(); + +// Mount the API first (before static files) +app.route('/api', api); + +// Serve static files from the 'dist' directory +app.use('/*', serveStatic({ root: './dist' })); + +// SPA fallback +app.get('*', serveStatic({ path: './dist/index.html' })); + +// Graceful shutdown handler +function gracefulShutdown(signal: string, server: ReturnType) { + console.log(`\n${signal} received. Shutting down gracefully...`); + + // Force exit after 10 seconds if graceful shutdown fails + const forceExitTimeout = setTimeout(() => { + console.error('Graceful shutdown timed out. Forcing exit.'); + process.exit(1); + }, 10000); + + server.close((err) => { + clearTimeout(forceExitTimeout); + if (err) { + console.error('Error during shutdown:', err); + process.exit(1); + } + console.log('Server closed successfully.'); + process.exit(0); + }); +} + +// Start server in production +if (process.env.NODE_ENV === 'production') { + const server = serve({ + fetch: app.fetch, + port: port, + }); + console.log(`Server is running on port ${port}`); + + // Handle shutdown signals + process.on('SIGTERM', () => gracefulShutdown('SIGTERM', server)); + process.on('SIGINT', () => gracefulShutdown('SIGINT', server)); +} + +export default app; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..fed6189 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import { RouterProvider } from 'react-router-dom'; +import { Toaster } from 'sonner'; +import ErrorDisplay from './components/ErrorDisplay'; +import { router } from './router'; +import { useThemeStore } from './store/themeStore'; + +function App() { + const { theme } = useThemeStore(); + + useEffect(() => { + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + }, [theme]); + + return ( + <> + + + + + ); +} + +export default App; diff --git a/src/components/AddUserModal.tsx b/src/components/AddUserModal.tsx new file mode 100644 index 0000000..e100f1c --- /dev/null +++ b/src/components/AddUserModal.tsx @@ -0,0 +1,123 @@ +import { Key, Mail, Shield, User } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from './Modal'; + +interface NewUser { + name: string; + username: string; + email: string; + password: string; + role: string; +} + +interface AddUserModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (newUser: NewUser) => void; +} + +export function AddUserModal({ isOpen, onClose, onSave }: AddUserModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState('user'); + + const handleSave = () => { + onSave({ + name, + username, + email, + password, + role, + }); + }; + + return ( + +
+
+ +
+ + setName(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + /> +
+
+
+ +
+ + setUsername(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + /> +
+
+
+ +
+ + setEmail(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + /> +
+
+
+ +
+ + setPassword(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + /> +
+
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 0000000..df95b39 --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,43 @@ +import { ReactNode } from 'react'; + +type GradientVariant = 'teal' | 'green' | 'purple' | 'amber' | 'none'; + +interface CardProps { + children: ReactNode; + gradient?: GradientVariant; + className?: string; + hover?: boolean; + noPadding?: boolean; +} + +const gradientStyles: Record = { + teal: 'bg-gradient-to-r from-teal-500 via-teal-400 to-teal-500', + green: 'bg-gradient-to-r from-green-500 via-teal-500 to-green-500', + purple: 'bg-gradient-to-r from-indigo-500 via-purple-500 to-indigo-500', + amber: 'bg-gradient-to-r from-amber-500 via-orange-500 to-amber-500', + none: '', +}; + +export function Card({ + children, + gradient = 'none', + className = '', + hover = false, + noPadding = false, +}: CardProps) { + const hoverStyles = hover + ? 'transition-shadow duration-300 hover:shadow-xl hover:shadow-gray-300/50 dark:hover:shadow-dark-900/70' + : ''; + const paddingStyles = noPadding ? '' : 'p-5 sm:p-8'; + + return ( +
+ {gradient !== 'none' && ( +
+ )} + {children} +
+ ); +} diff --git a/src/components/CreateButton.tsx b/src/components/CreateButton.tsx new file mode 100644 index 0000000..a4a605e --- /dev/null +++ b/src/components/CreateButton.tsx @@ -0,0 +1,42 @@ +import { Loader2, Send } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface CreateButtonProps { + onSubmit: () => void; + isLoading: boolean; + disabled: boolean; +} + +export function CreateButton({ onSubmit, isLoading, disabled }: CreateButtonProps) { + const { t } = useTranslation(); + + return ( +
+ +
+ ); +} diff --git a/src/components/EditUserModal.tsx b/src/components/EditUserModal.tsx new file mode 100644 index 0000000..6a0f31f --- /dev/null +++ b/src/components/EditUserModal.tsx @@ -0,0 +1,124 @@ +import { Ban, Mail, Shield, User } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from './Modal'; + +interface UserData { + id: string; + username: string; + email: string; + role: string; + banned: boolean; +} + +interface EditUserModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (user: UserData) => void; + user: UserData | null; +} + +export function EditUserModal({ isOpen, onClose, onSave, user }: EditUserModalProps) { + const { t } = useTranslation(); + const [username, setUsername] = React.useState(user?.username); + const [email, setEmail] = React.useState(user?.email); + const [role, setRole] = React.useState(user?.role); + const [banned, setBanned] = React.useState(user?.banned); + + React.useEffect(() => { + if (user) { + setUsername(user.username); + setEmail(user.email); + setRole(user.role); + setBanned(user.banned); + } + }, [user]); + + const handleSave = () => { + if (user) { + onSave({ + ...user, + username: username ?? '', + email: email ?? '', + role: role ?? 'user', + banned: banned ?? false, + }); + } + }; + + if (!user) { + return null; + } + + return ( + +
+
+ +
+ + setUsername(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + /> +
+
+
+ +
+ + setEmail(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + /> +
+
+
+ +
+ + +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx new file mode 100644 index 0000000..3a0949d --- /dev/null +++ b/src/components/Editor.tsx @@ -0,0 +1,1006 @@ +import { + IconBold, + IconBrandCodesandbox, + IconCode, + IconCopy, + IconCreditCard, + IconDatabase, + IconFileText, + IconH1, + IconH2, + IconH3, + IconItalic, + IconKey, + IconLetterP, + IconLink, + IconLinkOff, + IconList, + IconListNumbers, + IconMail, + IconNumber64Small, + IconPassword, + IconQuote, + IconRefresh, + IconServer, + IconSourceCode, + IconStrikethrough, +} from '@tabler/icons-react'; +import CharacterCount from '@tiptap/extension-character-count'; +import { Color } from '@tiptap/extension-color'; +import Link from '@tiptap/extension-link'; +import ListItem from '@tiptap/extension-list-item'; +import { TextStyle } from '@tiptap/extension-text-style'; +import { EditorProvider, useCurrentEditor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { generate } from 'generate-password-browser'; +import { + createContext, + FC, + ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +// Context for passing onChange to MenuBar +const EditorOnChangeContext = createContext<((content: string) => void) | undefined>(undefined); + +interface PasswordOptions { + numbers: boolean; + symbols: boolean; + uppercase: boolean; + lowercase: boolean; +} + +const generatePassword = ( + length = 16, + options: PasswordOptions = { numbers: true, symbols: true, uppercase: true, lowercase: true } +) => { + const password = generate({ + length, + numbers: options.numbers, + symbols: options.symbols, + uppercase: options.uppercase, + lowercase: options.lowercase, + }); + + return password; +}; + +// Tooltip component for buttons +interface TooltipProps { + text: string; + children: ReactNode; +} +const Tooltip: FC = ({ text, children }) => { + const [isVisible, setIsVisible] = useState(false); + + return ( +
+
setIsVisible(true)} onMouseLeave={() => setIsVisible(false)}> + {children} +
+ {isVisible && ( +
+ {text} +
+
+ )} +
+ ); +}; + +// Template definitions +interface Template { + id: string; + nameKey: string; + icon: ReactNode; + content: string; +} + +const templates: Template[] = [ + { + id: 'credentials', + nameKey: 'template_selector.templates.credentials', + icon: , + content: `

Login Credentials

+

Username:

+

Password:

+

URL:

+

Notes:

`, + }, + { + id: 'api_key', + nameKey: 'template_selector.templates.api_key', + icon: , + content: `

API Key

+

Service:

+

API Key:

+

API Secret:

+

Environment:

+

Expires:

`, + }, + { + id: 'database', + nameKey: 'template_selector.templates.database', + icon: , + content: `

Database Credentials

+

Host:

+

Port:

+

Database:

+

Username:

+

Password:

+

SSL:

`, + }, + { + id: 'server', + nameKey: 'template_selector.templates.server', + icon: , + content: `

Server Access

+

Hostname:

+

IP Address:

+

SSH Port:

+

Username:

+

Password / Key:

+

Notes:

`, + }, + { + id: 'credit_card', + nameKey: 'template_selector.templates.credit_card', + icon: , + content: `

Payment Card

+

Cardholder Name:

+

Card Number:

+

Expiry Date:

+

CVV:

+

Billing Address:

`, + }, + { + id: 'email', + nameKey: 'template_selector.templates.email', + icon: , + content: `

Email Account

+

Email:

+

Password:

+

IMAP Server:

+

SMTP Server:

+

Recovery Email:

`, + }, +]; + +// Template Dropdown Component for toolbar +interface TemplateDropdownProps { + onSelect: (content: string) => void; + disabled?: boolean; + buttonClass: string; +} + +const TemplateDropdown: FC = ({ onSelect, disabled, buttonClass }) => { + const [isOpen, setIsOpen] = useState(false); + const { t } = useTranslation(); + + const handleSelect = (template: Template) => { + onSelect(template.content); + setIsOpen(false); + }; + + return ( +
+ + + + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+

+ {t('template_selector.description')} +

+
+
+ {templates.map((template) => ( + + ))} +
+
+ + )} +
+ ); +}; + +// Password Dropdown Component for toolbar +interface PasswordDropdownProps { + onInsert: (password: string) => void; + buttonClass: string; +} + +const PasswordDropdown: FC = ({ onInsert, buttonClass }) => { + const [isOpen, setIsOpen] = useState(false); + const [passwordLength, setPasswordLength] = useState(16); + const [options, setOptions] = useState({ + numbers: true, + symbols: true, + uppercase: true, + lowercase: true, + }); + const [password, setPassword] = useState(() => generatePassword(16, options)); + const { t } = useTranslation(); + + const regeneratePassword = () => { + setPassword(generatePassword(passwordLength, options)); + }; + + const handleOptionChange = (option: keyof PasswordOptions) => { + const newOptions = { ...options, [option]: !options[option] }; + if (Object.values(newOptions).some((value) => value)) { + setOptions(newOptions); + setPassword(generatePassword(passwordLength, newOptions)); + } + }; + + const handleInsert = () => { + onInsert(password); + setIsOpen(false); + }; + + return ( +
+ + + + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+

+ {t('editor.password_modal.title')} +

+
+
+
+ + { + const newLength = parseInt(e.target.value); + setPasswordLength(newLength); + setPassword(generatePassword(newLength, options)); + }} + className="w-full accent-teal-500" + /> +
+ +
+ + + + +
+ +
+ + +
+ + +
+
+ + )} +
+ ); +}; + +// Link Dropdown Component for toolbar +interface LinkDropdownProps { + onSubmit: (url: string) => void; + buttonClass: string; + activeButtonClass: string; + isActive: boolean; + initialUrl?: string; +} + +const LinkDropdown: FC = ({ + onSubmit, + buttonClass, + activeButtonClass, + isActive, + initialUrl = '', +}) => { + const [isOpen, setIsOpen] = useState(false); + const [url, setUrl] = useState(initialUrl); + const inputRef = useRef(null); + const { t } = useTranslation(); + + useEffect(() => { + if (isOpen && inputRef.current) { + setTimeout(() => { + inputRef.current?.focus(); + }, 50); + } + }, [isOpen]); + + useEffect(() => { + if (isOpen) { + setUrl(initialUrl); + } + }, [isOpen, initialUrl]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(url); + setIsOpen(false); + setUrl(''); + }; + + return ( +
+ + + + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+
+

+ {t('editor.link_modal.title')} +

+
+
+
+ + setUrl(e.target.value)} + placeholder={t('editor.link_modal.url_placeholder')} + className="w-full px-2 py-1.5 text-xs bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-teal-500" + /> +
+ +
+
+ + )} +
+ ); +}; + +// ReadOnlyMenuBar component for non-editable mode +const ReadOnlyMenuBar: FC = () => { + const { editor } = useCurrentEditor(); + const [copySuccess, setCopySuccess] = useState(''); + const { t } = useTranslation(); + + if (!editor) { + return null; + } + + const copyAsHTML = () => { + const html = editor.getHTML(); + navigator.clipboard + .writeText(html) + .then(() => { + setCopySuccess(t('editor.copy_success.html')); + setTimeout(() => setCopySuccess(''), 2000); + }) + .catch((err) => { + console.error('Failed to copy: ', err); + }); + }; + + const copyAsPlainText = () => { + const text = editor.getText(); + navigator.clipboard + .writeText(text) + .then(() => { + setCopySuccess(t('editor.copy_success.text')); + setTimeout(() => setCopySuccess(''), 2000); + }) + .catch((err) => { + console.error('Failed to copy: ', err); + }); + }; + + const copyAsBase64 = () => { + const text = editor.getText(); + // Convert to Base64 in a way that is safe for large strings + const uint8Array = new TextEncoder().encode(text); + let binaryString = ''; + for (const byte of uint8Array) { + binaryString += String.fromCharCode(byte); + } + const base64Content = btoa(binaryString); + + navigator.clipboard + .writeText(base64Content) + .then(() => { + setCopySuccess(t('editor.copy_success.base64')); + setTimeout(() => setCopySuccess(''), 2000); + }) + .catch((err) => { + console.error('Failed to copy: ', err); + }); + }; + + const buttonClass = + 'p-2 bg-gray-200 dark:bg-dark-600/50 hover:bg-gray-300 dark:hover:bg-dark-500/50 text-gray-600 dark:text-slate-300 hover:text-gray-900 dark:hover:text-white transition-all duration-200 hover:scale-105'; + const groupClass = 'flex items-center gap-1'; + + return ( +
+
+
+ + + + + + + + + +
+
+ {copySuccess && ( +
+ {copySuccess} +
+ )} +
+ ); +}; + +const MenuBar: FC = () => { + const { editor } = useCurrentEditor(); + const onChange = useContext(EditorOnChangeContext); + const [menuOpen, setMenuOpen] = useState(false); + const { t } = useTranslation(); + + const handleLinkSubmit = useCallback( + (url: string) => { + if (!editor) return; + if (url === '') { + editor.chain().focus().extendMarkRange('link').unsetLink().run(); + return; + } + if (!/^https?:\/\//i.test(url)) { + url = 'https://' + url; + } + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + }, + [editor] + ); + + const handlePasswordSubmit = useCallback( + async (password: string) => { + if (!editor) return; + editor.chain().focus().insertContent(password).run(); + try { + await navigator.clipboard.writeText(password); + toast.success(t('editor.password_modal.copied_and_added')); + } catch { + toast.success(t('editor.password_modal.added')); + } + }, + [editor, t] + ); + + const handleTemplateSubmit = useCallback( + (content: string) => { + if (!editor) return; + editor.commands.setContent(content); + if (onChange) { + onChange(content); + } + }, + [editor, onChange] + ); + + if (!editor) { + return null; + } + + // Check if editor has content (templates disabled when there's existing content) + const editorHasContent = !editor.isEmpty; + + const buttonClass = + 'p-1.5 bg-gray-200 dark:bg-dark-600/50 hover:bg-gray-300 dark:hover:bg-dark-500/50 text-gray-600 dark:text-slate-300 hover:text-gray-900 dark:hover:text-white transition-all duration-200 hover:scale-105 min-w-[32px] touch-manipulation'; + const activeButtonClass = + 'p-1.5 bg-teal-500 text-white transition-all duration-200 min-w-[32px] touch-manipulation'; + + const groupClass = 'flex items-center gap-0.5'; + + const toggleMenu = () => { + setMenuOpen(!menuOpen); + }; + + return ( + <> +
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + + + + + + + +
+ +
+
+ +
+ + + + + + + + + + + + +
+
+
+
+ + ); +}; + +const extensions = [ + Color.configure({ types: [TextStyle.name, ListItem.name] }), + TextStyle.configure(), + Link.configure({ + openOnClick: false, + autolink: true, + defaultProtocol: 'https', + protocols: ['http', 'https'], + validate: (href) => /^https?:\/\//.test(href), + }), + StarterKit.configure({ + bulletList: { + keepMarks: true, + keepAttributes: false, + }, + orderedList: { + keepMarks: true, + keepAttributes: false, + }, + }), + CharacterCount, +]; + +interface EditorProps { + value?: string; + onChange?: (content: string) => void; + editable?: boolean; + onEditorReady?: (editor: { setContent: (content: string) => void }) => void; +} + +export default function Editor({ + value = '', + onChange, + editable = true, + onEditorReady, + ...props +}: EditorProps) { + const [characterCount, setCharacterCount] = useState(0); + const { t } = useTranslation(); + return ( + +
+ : } + extensions={extensions} + editable={editable} + content={value} + onUpdate={({ editor }) => { + if (onChange) { + if (editor.isEmpty) { + onChange(''); + } else { + onChange(editor.getHTML()); + } + } + setCharacterCount(editor.storage.characterCount.characters()); + }} + onCreate={({ editor }) => { + setCharacterCount(editor.storage.characterCount.characters()); + if (onEditorReady) { + onEditorReady({ + setContent: (content: string) => { + editor.commands.setContent(content); + if (onChange) { + onChange(content); + } + }, + }); + } + }} + editorProps={{ + attributes: { + class: 'w-full min-h-[12rem] sm:min-h-[16rem] p-4 sm:p-6 bg-gray-100 dark:bg-dark-700/50 border border-gray-300 dark:border-dark-500/50 text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50 transition-all duration-300 text-sm sm:text-base prose prose-sm max-w-none prose-headings:mt-6 prose-headings:first:mt-0 prose-headings:text-gray-900 dark:prose-headings:text-slate-100 prose-h1:text-2xl prose-h1:font-bold prose-h1:mb-4 prose-h2:text-xl prose-h2:font-bold prose-h2:mb-3 prose-h3:text-lg prose-h3:font-semibold prose-h3:mb-3 prose-p:my-3 prose-p:leading-relaxed prose-p:text-gray-800 dark:prose-p:text-slate-200 prose-strong:text-gray-900 dark:prose-strong:text-slate-200 prose-strong:font-bold prose-em:text-gray-800 dark:prose-em:text-slate-200 prose-ul:pl-5 prose-ul:my-3 prose-ol:pl-5 prose-ol:my-3 prose-li:my-1 prose-li:leading-normal prose-li:text-gray-800 dark:prose-li:text-slate-200 prose-a:text-teal-600 dark:prose-a:text-teal-400 prose-a:underline prose-a:font-medium hover:prose-a:text-teal-500 dark:hover:prose-a:text-teal-300 prose-code:bg-gray-200 dark:prose-code:bg-dark-800 prose-code:text-gray-800 dark:prose-code:text-slate-200 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-mono prose-pre:bg-gray-200 dark:prose-pre:bg-dark-900 prose-pre:text-gray-900 dark:prose-pre:text-white prose-pre:p-4 prose-pre:my-4 prose-pre:overflow-auto prose-pre:code:bg-transparent prose-pre:code:p-0 prose-pre:code:text-sm prose-pre:code:font-mono prose-blockquote:border-l-4 prose-blockquote:border-gray-300 dark:prose-blockquote:border-dark-500 prose-blockquote:pl-4 prose-blockquote:py-1 prose-blockquote:my-4 prose-blockquote:italic prose-blockquote:text-gray-600 dark:prose-blockquote:text-slate-300 prose-hr:my-6 prose-hr:border-gray-300 dark:prose-hr:border-dark-600', + }, + }} + {...props} + > +
+ {characterCount} {t('editor.character_count')} +
+
+
+
+ ); +} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..7c2f2a9 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,66 @@ +import { AlertOctagon, Home, RotateCcw } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { isRouteErrorResponse, Link, useRouteError } from 'react-router-dom'; + +export function ErrorBoundary() { + const error = useRouteError(); + const { t } = useTranslation(); + + const isRouteError = isRouteErrorResponse(error); + const errorMessage = isRouteError + ? error.statusText + : error instanceof Error + ? error.message + : t('error_boundary.unknown_error'); + + const handleReload = () => { + window.location.reload(); + }; + + return ( +
+
+
+
+ +
+
+

+ {t('error_boundary.title')} +

+

+ {t('error_boundary.message')} +

+

+ {t('error_boundary.hint')} +

+ + {errorMessage && ( +
+

{t('error_boundary.error_details')}

+
+                            {errorMessage}
+                        
+
+ )} + +
+ + + + {t('error_boundary.go_home_button')} + +
+
+
+ ); +} diff --git a/src/components/ErrorDisplay.tsx b/src/components/ErrorDisplay.tsx new file mode 100644 index 0000000..6212d3e --- /dev/null +++ b/src/components/ErrorDisplay.tsx @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useErrorStore } from '../store/errorStore'; + +const ErrorDisplay = () => { + const { errors, clearErrors } = useErrorStore(); + const { t } = useTranslation(); + + useEffect(() => { + if (errors.length > 0) { + const timer = setTimeout(() => { + clearErrors(); + }, 5000); // Clear errors after 5 seconds + return () => clearTimeout(timer); + } + }, [errors, clearErrors]); + + if (errors.length === 0) { + return null; + } + + return ( +
+ {errors.map((error, index) => ( +
+ {error} + +
+ ))} +
+ ); +}; + +export default ErrorDisplay; diff --git a/src/components/ExpirationSelect.tsx b/src/components/ExpirationSelect.tsx new file mode 100644 index 0000000..b67d763 --- /dev/null +++ b/src/components/ExpirationSelect.tsx @@ -0,0 +1,76 @@ +import { ChevronDown } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { useHemmeligStore } from '../store/hemmeligStore'; + +interface ExpirationSelectProps { + value?: number; + onChange: (expiration?: number) => void; +} + +export function ExpirationSelect({ value, onChange }: ExpirationSelectProps) { + const { settings: instanceSettings } = useHemmeligStore(); + const { t } = useTranslation(); + + const defaultExpirationInSeconds = instanceSettings?.defaultSecretExpiration + ? instanceSettings.defaultSecretExpiration * 3600 + : undefined; + + const baseOptions = [ + { value: 2419200, label: t('expiration.28_days') }, + { value: 1209600, label: t('expiration.14_days') }, + { value: 604800, label: t('expiration.7_days') }, + { value: 259200, label: t('expiration.3_days') }, + { value: 86400, label: t('expiration.1_day') }, + { value: 43200, label: t('expiration.12_hours') }, + { value: 14400, label: t('expiration.4_hours') }, + { value: 3600, label: t('expiration.1_hour') }, + { value: 1800, label: t('expiration.30_minutes') }, + { value: 300, label: t('expiration.5_minutes') }, + ]; + + const options = + defaultExpirationInSeconds && + !baseOptions.some((opt) => opt.value === defaultExpirationInSeconds) + ? [ + { + value: defaultExpirationInSeconds, + label: t('expiration.default_hours', { + hours: instanceSettings.defaultSecretExpiration, + }), + }, + ...baseOptions, + ] + : baseOptions; + + const getCurrentValue = () => { + return value !== undefined ? value : defaultExpirationInSeconds; + }; + + const handleChange = (expiration: number) => { + onChange(expiration); + }; + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx new file mode 100644 index 0000000..1e0195a --- /dev/null +++ b/src/components/FileUpload.tsx @@ -0,0 +1,154 @@ +import { File as FileIcon, Lock, UploadCloud, X } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { useHemmeligStore } from '../store/hemmeligStore'; +import { useUserStore } from '../store/userStore'; + +interface FileUploadProps { + onFileChange: (files: File[]) => void; + compact?: boolean; +} + +export function FileUpload({ onFileChange, compact = false }: FileUploadProps) { + const { t } = useTranslation(); + const { user } = useUserStore(); + const { settings: instanceSettings } = useHemmeligStore(); + const [files, setFiles] = useState([]); + const [totalSize, setTotalSize] = useState(0); + const [fileError, setFileError] = useState(null); + + const maxFileSizeInBytes = instanceSettings.maxSecretSize * 1024; // Convert KB to bytes + + const onDropRejected = useCallback( + (fileRejections: { file: File; errors: { code: string }[] }[]) => { + const rejection = fileRejections[0]; + if (rejection?.errors.some((e) => e.code === 'file-too-large')) { + const fileSizeMB = (rejection.file.size / 1024 / 1024).toFixed(2); + const maxSizeMB = (instanceSettings.maxSecretSize / 1024).toFixed(2); + setFileError( + t('file_upload.file_too_large', { + fileName: rejection.file.name, + fileSize: fileSizeMB, + maxSize: maxSizeMB, + }) + ); + } + }, + [instanceSettings.maxSecretSize, t] + ); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + setFileError(null); + const newFilesTotalSize = acceptedFiles.reduce((sum, file) => sum + file.size, 0); + + if (totalSize + newFilesTotalSize > maxFileSizeInBytes) { + const maxSizeMB = (instanceSettings.maxSecretSize / 1024).toFixed(2); + setFileError(t('file_upload.max_size_exceeded', { maxSize: maxSizeMB })); + return; + } + + const newFiles = [...files, ...acceptedFiles]; + setFiles(newFiles); + setTotalSize(totalSize + newFilesTotalSize); + onFileChange(newFiles); + }, + [files, totalSize, onFileChange, maxFileSizeInBytes, instanceSettings.maxSecretSize, t] + ); + + const removeFile = (fileToRemove: File) => { + const newFiles = files.filter((file) => file !== fileToRemove); + setFiles(newFiles); + setTotalSize(totalSize - fileToRemove.size); + onFileChange(newFiles); + }; + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + onDropRejected, + maxSize: maxFileSizeInBytes, // Enforce max size per file as well + }); + + // Check if file uploads are disabled at the instance level + if (instanceSettings.allowFileUploads === false) { + return null; + } + + if (!user) { + return ( +
+
+
+ +
+ + {t('file_upload.sign_in_to_upload')} + + + {t('file_upload.sign_in')} + +
+
+ ); + } + + return ( +
+
+ +
+ + {isDragActive ? ( +

+ {t('file_upload.drop_files_here')} +

+ ) : ( +

+ {t('file_upload.drag_and_drop')} +

+ )} +
+
+ {files.length > 0 && ( +
+ {files.map((file, index) => ( +
+
+ + + {file.name} + +
+ +
+ ))} +
+ )} + {fileError &&

{fileError}

} +
+ ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..2da29f9 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,67 @@ +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { LanguagePicker } from './LanguagePicker'; +import { ThemeToggle } from './ThemeToggle'; + +export function Footer() { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..27609e3 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,99 @@ +import { CircleUser, Home, LogIn, UserPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { useHemmeligStore } from '../store/hemmeligStore.ts'; +import { useUserStore } from '../store/userStore.ts'; +import Logo from './Logo.tsx'; + +export function Header() { + const { t } = useTranslation(); + const { user } = useUserStore(); + const { settings } = useHemmeligStore(); + + return ( +
+
+ {/* Navigation */} +
+ + + {t('header.home')} + +
+ {user ? ( + + + {t('header.dashboard')} + + ) : ( + <> + + + {t('header.sign_in')} + + + + {t('header.sign_up')} + + + )} +
+
+ + {/* Hero Section */} +
+
+
+ {settings.instanceLogo ? ( + {settings.instanceName + ) : ( + + )} +
+
+ + {settings.instanceName ? ( +

+ {settings.instanceName} +

+ ) : ( +

+ paste + .es +

+ )} + + {settings.instanceDescription ? ( +

+ {settings.instanceDescription} +

+ ) : ( +

+ {t('header.hero_text_part1')} + + {t('header.hero_text_part2')} + + {t('header.hero_text_part3')} +

+ )} +
+
+
+ ); +} diff --git a/src/components/ImportantAlert.tsx b/src/components/ImportantAlert.tsx new file mode 100644 index 0000000..46cfdd7 --- /dev/null +++ b/src/components/ImportantAlert.tsx @@ -0,0 +1,50 @@ +import { Info, X } from 'lucide-react'; +import { useState } from 'react'; +import Markdown from 'react-markdown'; +import { hashString } from '../lib/hash'; +import { useHemmeligStore } from '../store/hemmeligStore'; + +const DISMISS_KEY_PREFIX = 'importantAlertDismissed_'; + +export function ImportantAlert() { + const { settings } = useHemmeligStore(); + + const getDismissKey = () => DISMISS_KEY_PREFIX + hashString(settings.importantMessage || ''); + + const isDismissedInStorage = () => { + if (!settings.importantMessage) return false; + const dismissedUntil = localStorage.getItem(getDismissKey()); + if (!dismissedUntil) return false; + return Date.now() < parseInt(dismissedUntil, 10); + }; + + const [dismissed, setDismissed] = useState(isDismissedInStorage); + + const handleDismiss = () => { + const sevenDaysFromNow = Date.now() + 7 * 24 * 60 * 60 * 1000; + localStorage.setItem(getDismissKey(), sevenDaysFromNow.toString()); + setDismissed(true); + }; + + if (!settings.importantMessage || dismissed) { + return null; + } + + return ( +
+
+ +
+ {settings.importantMessage} +
+ +
+
+ ); +} diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx new file mode 100644 index 0000000..4dff71a --- /dev/null +++ b/src/components/LanguagePicker.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; + +const LANGUAGES = [ + { code: 'en', label: 'EN' }, + { code: 'da', label: 'DA' }, + { code: 'de', label: 'DE' }, + { code: 'es', label: 'ES' }, + { code: 'fr', label: 'FR' }, + { code: 'it', label: 'IT' }, + { code: 'nl', label: 'NL' }, + { code: 'no', label: 'NO' }, + { code: 'sv', label: 'SV' }, + { code: 'zh', label: '中文' }, +] as const; + +export function LanguagePicker() { + const { i18n } = useTranslation(); + + const handleLanguageChange = (e: React.ChangeEvent) => { + i18n.changeLanguage(e.target.value); + }; + + return ( + + ); +} diff --git a/src/components/Layout/DashboardLayout.tsx b/src/components/Layout/DashboardLayout.tsx new file mode 100644 index 0000000..a4a52b6 --- /dev/null +++ b/src/components/Layout/DashboardLayout.tsx @@ -0,0 +1,240 @@ +import { + BarChart3, + Link2, + LogOut, + Menu, + Server, + Shield, + Ticket, + User, + Users, + X, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, Outlet, useLocation } from 'react-router-dom'; +import { authClient } from '../../lib/auth'; +import { useHemmeligStore } from '../../store/hemmeligStore'; +import { useUserStore } from '../../store/userStore'; +import Logo from '../Logo'; + +export function DashboardLayout() { + const { t } = useTranslation(); + const location = useLocation(); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { user, isLoading } = useUserStore(); + const { settings } = useHemmeligStore(); + + const handleLogout = async () => { + await authClient.signOut(); + window.location.href = '/login'; + }; + + const navigation = [ + { name: t('dashboard_layout.secrets'), href: '/dashboard', icon: Shield }, + { + name: t('dashboard_layout.secret_requests'), + href: '/dashboard/secret-requests', + icon: Link2, + }, + { name: t('dashboard_layout.account'), href: '/dashboard/account', icon: User }, + ...(user?.isAdmin + ? [ + { + name: t('dashboard_layout.analytics'), + href: '/dashboard/analytics', + icon: BarChart3, + }, + { name: t('dashboard_layout.users'), href: '/dashboard/users', icon: Users }, + ...(settings.requireInviteCode + ? [ + { + name: t('dashboard_layout.invites'), + href: '/dashboard/invites', + icon: Ticket, + }, + ] + : []), + { + name: t('dashboard_layout.instance'), + href: '/dashboard/instance', + icon: Server, + }, + ] + : []), + ]; + + const isActive = (href: string) => { + if (href === '/dashboard') { + return location.pathname === '/dashboard'; + } + return location.pathname.startsWith(href); + }; + + return ( +
+
+ {/* Background pattern */} +
+ + {/* Mobile menu overlay */} + {isMobileMenuOpen && ( +
+
setIsMobileMenuOpen(false)} + /> +
+
+ + + + {t('dashboard_layout.hemmelig')} + + + +
+ +
+
+ )} + +
+ {/* Desktop Sidebar */} +
+
+ {/* Logo */} +
+ + + paste.es + +
+ + {/* Navigation */} + + + {/* User info */} +
+ +
+ +
+
+ {isLoading ? ( +

+ {t('common.loading')} +

+ ) : ( + <> +

+ {user?.username} +

+

+ {user?.email} +

+ + )} +
+ + +
+
+
+ + {/* Main content */} +
+ {/* Mobile header */} +
+ + + + paste.es + +
+ + {/* Page content */} +
+ +
+
+
+
+
+ ); +} diff --git a/src/components/Layout/RootLayout.tsx b/src/components/Layout/RootLayout.tsx new file mode 100644 index 0000000..b5b18a2 --- /dev/null +++ b/src/components/Layout/RootLayout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom'; +import { Footer } from '../Footer'; +import { Header } from '../Header'; + +export function RootLayout() { + return ( +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx new file mode 100644 index 0000000..28a1720 --- /dev/null +++ b/src/components/Logo.tsx @@ -0,0 +1,28 @@ +const Logo = ({ className, ...rest }) => ( + + + + + + + +); + +export default Logo; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..fc1d7d6 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,61 @@ +import { X } from 'lucide-react'; +import React from 'react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + onConfirm?: () => void; + confirmText?: string; + cancelText?: string; + confirmButtonClass?: string; +} + +export const Modal: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + children, + confirmText, + cancelText, + confirmButtonClass = 'bg-red-500 hover:bg-red-600', +}) => { + if (!isOpen) return null; + + return ( +
+
+
+

{title}

+ +
+
{children}
+
+ {cancelText && ( + + )} + {onConfirm && confirmText && ( + + )} +
+
+
+ ); +}; diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx new file mode 100644 index 0000000..588ad1c --- /dev/null +++ b/src/components/Pagination.tsx @@ -0,0 +1,85 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + totalItems: number; + pageSize: number; + onPageChange: (page: number) => void; +} + +function getVisiblePages(current: number, total: number): (number | null)[] { + if (total <= 5) { + return Array.from({ length: total }, (_, i) => i + 1); + } + + if (current <= 3) return [1, 2, 3, 4, null, total]; + if (current >= total - 2) return [1, null, total - 3, total - 2, total - 1, total]; + + return [1, null, current - 1, current, current + 1, null, total]; +} + +export function Pagination({ + currentPage, + totalPages, + totalItems, + pageSize, + onPageChange, +}: PaginationProps) { + const { t } = useTranslation(); + + if (totalPages <= 1) return null; + + const startItem = (currentPage - 1) * pageSize + 1; + const endItem = Math.min(currentPage * pageSize, totalItems); + const pages = getVisiblePages(currentPage, totalPages); + + return ( +
+ + {t('pagination.showing', { start: startItem, end: endItem, total: totalItems })} + + +
+ + + {pages.map((page, i) => + page === null ? ( + + … + + ) : ( + + ) + )} + + +
+
+ ); +} diff --git a/src/components/SecretForm.tsx b/src/components/SecretForm.tsx new file mode 100644 index 0000000..82f6a68 --- /dev/null +++ b/src/components/SecretForm.tsx @@ -0,0 +1,171 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api } from '../lib/api'; +import { encrypt, encryptFile, generateEncryptionKey, generateSalt } from '../lib/crypto'; +import { useHemmeligStore } from '../store/hemmeligStore'; +import { useSecretStore } from '../store/secretStore'; +import { Card } from './Card'; +import { CreateButton } from './CreateButton'; +import Editor from './Editor'; +import { FileUpload } from './FileUpload'; +import { Modal } from './Modal'; +import { SecuritySettings } from './SecuritySettings'; +import { TitleField } from './TitleField'; + +export function SecretForm() { + const { settings: instanceSettings } = useHemmeligStore(); + const { + secret, + title, + password, + expiresAt, + views, + isBurnable, + ipRange, + setSecretIdAndKeys, + setSecretData, + } = useSecretStore(); + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [files, setFiles] = useState([]); + const [isErrorModalOpen, setIsErrorModalOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + + const handleFileChange = (files: File[]) => { + setFiles(files); + }; + + const handleSubmit = async () => { + setIsLoading(true); + + const encryptionKey = generateEncryptionKey(password); + const salt = generateSalt(); + + const fileIds = []; + if (files.length > 0) { + for (const file of files) { + try { + const encryptedFile = await encryptFile( + await file.arrayBuffer(), + encryptionKey, + salt + ); + const encryptedFileAsFile = new File([encryptedFile], file.name, { + type: file.type, + }); + + const response = await api.files.$post({ + form: { + file: encryptedFileAsFile, + }, + }); + const data = await response.json(); + if (response.ok) { + fileIds.push(data.id); + } else { + throw new Error(data.error || 'File upload failed'); + } + } catch (error) { + setErrorMessage( + t('secret_form.failed_to_upload_file', { fileName: file.name }) + ); + setIsErrorModalOpen(true); + setIsLoading(false); + console.error('File upload failed:', error); + return; + } + } + } + + const encryptedSecret = await encrypt(secret, encryptionKey, salt); + const encryptedTitle = await encrypt(title, encryptionKey, salt); + + // Transform empty strings to null for nullable fields + const dataToSend = { + secret: encryptedSecret, + title: encryptedTitle, + salt, + password: password ? encryptionKey : '', + expiresAt, + views, + isBurnable, + ipRange: ipRange === '' ? null : ipRange, + fileIds, + }; + + try { + const response = await api.secrets.$post({ json: dataToSend }); + const data = await response.json(); + + if (response.ok && data?.id) { + setSecretIdAndKeys(data.id, encryptionKey, password); + } else { + const errorMessage = + data?.error?.issues?.[0]?.message || + data?.error?.message || + 'An unknown error occurred.'; + setErrorMessage( + t('secret_form.failed_to_create_secret', { errorMessage: errorMessage }) + ); + setIsErrorModalOpen(true); + } + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'An unknown error occurred.'; + setErrorMessage( + t('secret_form.failed_to_create_secret', { errorMessage: errorMessage }) + ); + setIsErrorModalOpen(true); + console.error('Failed to create secret:', errorMessage); + // Handle error, e.g., show a toast notification + } finally { + setIsLoading(false); + } + }; + + const isFormValid = secret.trim().length > 0; + + return ( +
+ + setSecretData({ secret: value })} /> + +
+ setSecretData({ title: value })} + /> +
+ + {/* File upload and quick create button */} +
+
+ +
+
+ +
+
+
+ + + + {/* Create button */} + + setIsErrorModalOpen(false)} + title={t('common.error')} + confirmText={t('common.ok')} + onConfirm={() => setIsErrorModalOpen(false)} + > +

{errorMessage}

+
+
+ ); +} diff --git a/src/components/SecretSettings.tsx b/src/components/SecretSettings.tsx new file mode 100644 index 0000000..420af46 --- /dev/null +++ b/src/components/SecretSettings.tsx @@ -0,0 +1,168 @@ +import { Check, Copy, Eye, EyeOff, Plus } from 'lucide-react'; +import { QRCodeCanvas } from 'qrcode.react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { api } from '../lib/api'; +import { useHemmeligStore } from '../store/hemmeligStore'; +import { useSecretStore } from '../store/secretStore'; +import { copyToClipboard as copyText } from '../utils/clipboard'; +import { Card } from './Card'; + +export const SecretSettings = () => { + const { secretId, decryptionKey, password, resetSecret } = useSecretStore(); + const { t } = useTranslation(); + const { settings: instanceSettings } = useHemmeligStore(); + const secretUrl = `${window.location.origin}/secret/${secretId}${!password ? `#decryptionKey=${decryptionKey}` : ''}`; + const [copied, setCopied] = useState(null); + const [showPassword, setShowPassword] = useState(false); + + useEffect(() => { + if (copied) { + const timer = setTimeout(() => setCopied(null), 2000); + return () => clearTimeout(timer); + } + }, [copied]); + + const handleCopyToClipboard = async (text: string, field: string) => { + const success = await copyText(text); + if (success) { + setCopied(field); + } + }; + + const handleBurnSecret = async () => { + try { + await api.secrets[':id'].$delete({ param: { id: secretId } }); + resetSecret(); + } catch (error) { + console.error('Failed to burn secret:', error); + toast.error(t('secret_settings.failed_to_burn')); + } + }; + + return ( + + {/* Success header */} +
+
+ +
+

+ {t('secret_settings.secret_created_title')} +

+

+ {t('secret_settings.secret_created_description')} +

+
+ + {/* QR Code */} +
+
+ +
+
+ +
+
+ +
+ + +
+
+ {password && ( +
+ +
+ +
+ + +
+
+
+ )} +
+ + {/* Action buttons */} +
+ +
+ + +
+
+ {instanceSettings?.maxSecretsPerUser && ( +

+ {t('secret_settings.max_secrets_per_user_info', { + count: instanceSettings.maxSecretsPerUser, + })} +

+ )} +
+ ); +}; diff --git a/src/components/SecuritySettings.tsx b/src/components/SecuritySettings.tsx new file mode 100644 index 0000000..5f73246 --- /dev/null +++ b/src/components/SecuritySettings.tsx @@ -0,0 +1,241 @@ +import { Clock, Eye, Flame, Globe, Key, Save } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHemmeligStore } from '../store/hemmeligStore'; +import { useSecretSettingsStore } from '../store/secretSettingsStore'; +import { useSecretStore } from '../store/secretStore'; +import { Card } from './Card'; +import { ExpirationSelect } from './ExpirationSelect'; +import { ToggleSwitch } from './ToggleSwitch'; +import { ViewsSlider } from './ViewsSlider'; + +export function SecuritySettings() { + const { expiresAt, views, isBurnable, password, ipRange, setSecretData } = useSecretStore(); + const { saveSettings, setSaveSettings, updateSettings } = useSecretSettingsStore(); + const { settings: instanceSettings } = useHemmeligStore(); + const { t } = useTranslation(); + + const [isPasswordEnabled, setIsPasswordEnabled] = useState(!!password); + + // Sync settings to localStorage when saveSettings is enabled + useEffect(() => { + if (saveSettings) { + updateSettings({ expiresAt, views, isBurnable }); + } + }, [expiresAt, views, isBurnable, saveSettings, updateSettings]); + + const handleIpRangeToggle = (enabled: boolean) => { + if (enabled) { + setSecretData({ ipRange: '' }); + } else { + setSecretData({ ipRange: null }); + } + }; + + const handleBurnAfterTimeToggle = (checked: boolean) => { + setSecretData({ isBurnable: checked }); + + // When enabling burn after time, set a default expiration if none exists + if (checked && !expiresAt) { + const defaultExpiration = 14400; // Default to 4 hours in seconds + setSecretData({ expiresAt: defaultExpiration }); + } + }; + + return ( + +
+
+

+ {t('security_settings.security_title')} +

+

+ {t('security_settings.security_description')} +

+
+
+ + + {t('security_settings.remember_settings')} + + +
+
+ +
+ {/* Expiration and Views - Mobile-first responsive grid */} +
+ {/* Expiration - Always visible */} +
+
+
+ +
+ + {t('security_settings.expiration_title')} + +
+ setSecretData({ expiresAt: value })} + /> +

+ {isBurnable + ? t('security_settings.expiration_burn_after_time_description') + : t('security_settings.expiration_default_description')} +

+
+ + {/* Max Views - Only show when burn after time is NOT enabled */} + {!isBurnable && ( +
+
+
+ +
+ + {t('security_settings.max_views_title')} + +
+ setSecretData({ views: value })} + /> +
+ )} +
+ + {/* Burn After Time Notice - Mobile optimized */} + {isBurnable && ( +
+
+
+ +
+
+

+ {t('security_settings.burn_after_time_mode_title')} +

+

+ {t('security_settings.burn_after_time_mode_description')} +

+
+
+
+ )} + + {/* Additional Security Options - Mobile optimized */} +
+ {/* Password Protection */} + {instanceSettings.allowPasswordProtection && ( +
+
+
+
+ +
+ + {t('security_settings.password_protection_title')} + +
+ { + setIsPasswordEnabled(val); + if (!val) setSecretData({ password: null }); + }} + /> +
+ + {isPasswordEnabled && ( +
+

+ {t('security_settings.password_protection_description')} +

+ + setSecretData({ password: e.target.value }) + } + placeholder={t('security_settings.password_placeholder')} + minLength={5} + className={`w-full px-3 py-2 bg-white dark:bg-dark-600 border text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-400 focus:outline-none focus:ring-2 transition-all duration-200 text-sm ${ + password && password.length > 0 && password.length < 5 + ? 'border-red-500 dark:border-red-500 focus:ring-red-500/30' + : 'border-gray-300 dark:border-dark-500 focus:ring-blue-500/30 focus:border-blue-500' + }`} + /> + {password && password.length > 0 && password.length < 5 ? ( +

+ {t('security_settings.password_error')} +

+ ) : ( +

+ {t('security_settings.password_hint')} +

+ )} +
+ )} +
+ )} + + {/* IP Restriction */} + {instanceSettings.allowIpRestriction && ( +
+
+
+
+ +
+ + {t('security_settings.ip_restriction_title')} + +
+ +
+ + {ipRange !== null && ipRange !== undefined && ( +
+

+ {t('security_settings.ip_restriction_description')} +

+ setSecretData({ ipRange: e.target.value })} + placeholder={t( + 'security_settings.ip_address_cidr_placeholder' + )} + className="w-full px-3 py-2 bg-white dark:bg-dark-600 border border-gray-300 dark:border-dark-500 text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-500/30 focus:border-purple-500 transition-all duration-200 text-sm" + /> +
+ )} +
+ )} + + {/* Burn After Time */} +
+
+
+
+ +
+ + {t('security_settings.burn_after_time_title')} + +
+ +
+
+
+
+
+ ); +} diff --git a/src/components/SocialLoginButtons.tsx b/src/components/SocialLoginButtons.tsx new file mode 100644 index 0000000..ecaf8d1 --- /dev/null +++ b/src/components/SocialLoginButtons.tsx @@ -0,0 +1,266 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { apiRaw } from '../lib/api'; +import { authClient } from '../lib/auth'; + +type SocialProvider = + | 'github' + | 'google' + | 'microsoft' + | 'discord' + | 'gitlab' + | 'apple' + | 'twitter' + | string; // Allow any string for generic OAuth providers + +interface SocialLoginButtonsProps { + mode: 'login' | 'register'; +} + +// SVG Icons for each provider +const GithubIcon = () => ( + + + +); + +const GoogleIcon = () => ( + + + + + + +); + +const MicrosoftIcon = () => ( + + + + + + +); + +const DiscordIcon = () => ( + + + +); + +const GitLabIcon = () => ( + + + +); + +const AppleIcon = () => ( + + + +); + +const TwitterIcon = () => ( + + + +); + +// Generic OAuth icon for unknown providers (shield with checkmark) +const GenericOAuthIcon = () => ( + + + +); + +// Provider display configuration +const providerConfig: Record< + SocialProvider, + { name: string; icon: React.ReactNode; buttonClass: string } +> = { + github: { + name: 'GitHub', + icon: , + buttonClass: + 'bg-gray-900 hover:bg-black text-white dark:bg-gray-800 dark:hover:bg-gray-700', + }, + google: { + name: 'Google', + icon: , + buttonClass: 'bg-white hover:bg-gray-50 text-gray-900 border-gray-300 dark:border-gray-600', + }, + microsoft: { + name: 'Microsoft', + icon: , + buttonClass: 'bg-white hover:bg-gray-50 text-gray-900 dark:bg-gray-100 dark:hover:bg-white', + }, + discord: { + name: 'Discord', + icon: , + buttonClass: 'bg-[#5865F2] hover:bg-[#4752C4] text-white', + }, + gitlab: { + name: 'GitLab', + icon: , + buttonClass: 'bg-[#FC6D26] hover:bg-[#E24329] text-white', + }, + apple: { + name: 'Apple', + icon: , + buttonClass: 'bg-black hover:bg-gray-900 text-white', + }, + twitter: { + name: 'X', + icon: , + buttonClass: 'bg-black hover:bg-gray-900 text-white', + }, +}; + +export function SocialLoginButtons({ mode }: SocialLoginButtonsProps) { + const { t } = useTranslation(); + const [providers, setProviders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchProviders = async () => { + try { + const res = await apiRaw.config['social-providers'].$get(); + if (res.ok) { + const data = await res.json(); + setProviders(data.providers as string[]); + } + } catch (error) { + console.error('Failed to fetch social providers:', error); + } finally { + setIsLoading(false); + } + }; + fetchProviders(); + }, []); + + const handleSocialLogin = async (provider: string) => { + try { + // Check if it's a known standard provider + const standardProviders = [ + 'github', + 'google', + 'microsoft', + 'discord', + 'gitlab', + 'apple', + 'twitter', + ]; + + if (standardProviders.includes(provider)) { + // Use standard social sign-in + await authClient.signIn.social({ + provider: provider as any, + callbackURL: '/dashboard', + }); + } else { + // Use OAuth2 sign-in for generic providers + const response = await fetch('/api/auth/sign-in/oauth2', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + providerId: provider, + callbackURL: window.location.origin + '/dashboard', + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.url) { + window.location.href = data.url; + } + } else { + console.error(`OAuth2 login failed for ${provider}`); + } + } + } catch (error) { + console.error(`${provider} login failed:`, error); + } + }; + + if (isLoading || providers.length === 0) { + return null; + } + + return ( + <> + {/* Divider */} +
+
+
+
+
+ + {t('login_page.or_continue_with')} + +
+
+ + {/* Social Login Buttons */} +
+ {providers.map((provider) => { + const config = providerConfig[provider as keyof typeof providerConfig]; + + // If config exists, it's a known provider + if (config) { + const buttonText = + mode === 'login' + ? t('social_login.continue_with', { provider: config.name }) + : t('social_login.sign_up_with', { provider: config.name }); + + return ( + + ); + } + + // Generic OAuth provider (unknown provider) + // Capitalize first letter for display name + const displayName = provider.charAt(0).toUpperCase() + provider.slice(1); + const buttonText = + mode === 'login' + ? t('social_login.continue_with', { provider: displayName }) + : t('social_login.sign_up_with', { provider: displayName }); + + return ( + + ); + })} +
+ + ); +} diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx new file mode 100644 index 0000000..10d46ec --- /dev/null +++ b/src/components/Sparkline.tsx @@ -0,0 +1,53 @@ +interface SparklineProps { + data: number[]; + width?: number; + height?: number; + color?: string; + className?: string; +} + +export function Sparkline({ + data, + width = 80, + height = 24, + color = 'currentColor', + className = '', +}: SparklineProps) { + if (data.length === 0) return null; + + const max = Math.max(...data, 1); + const min = Math.min(...data, 0); + const range = max - min || 1; + + const padding = 2; + const chartWidth = width - padding * 2; + const chartHeight = height - padding * 2; + + const points = data + .map((value, index) => { + const x = padding + (index / (data.length - 1 || 1)) * chartWidth; + const y = padding + chartHeight - ((value - min) / range) * chartHeight; + return `${x},${y}`; + }) + .join(' '); + + // Create fill polygon by closing the path along the bottom + const fillPoints = + `${padding},${padding + chartHeight} ` + + points + + ` ${padding + chartWidth},${padding + chartHeight}`; + + return ( + + + + + ); +} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..08b9813 --- /dev/null +++ b/src/components/ThemeToggle.tsx @@ -0,0 +1,22 @@ +import { Moon, Sun } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useThemeStore } from '../store/themeStore'; + +export function ThemeToggle() { + const { theme, toggleTheme } = useThemeStore(); + const { t } = useTranslation(); + + return ( + + ); +} diff --git a/src/components/TitleField.tsx b/src/components/TitleField.tsx new file mode 100644 index 0000000..94a078f --- /dev/null +++ b/src/components/TitleField.tsx @@ -0,0 +1,37 @@ +import { Hash } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface TitleFieldProps { + value: string; + onChange: (value: string) => void; +} + +export function TitleField({ value, onChange }: TitleFieldProps) { + const [isFocused, setIsFocused] = useState(false); + const { t } = useTranslation(); + + return ( +
+
+
+ +
+ onChange(e.target.value)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder={t('title_field.placeholder')} + className="w-full pl-10 pr-4 py-2 bg-gray-100 dark:bg-dark-700/50 border border-gray-300 dark:border-dark-500/50 text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50 transition-all duration-300" + /> +
+

+ {t('title_field.hint')} +

+
+ ); +} diff --git a/src/components/ToggleSwitch.tsx b/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000..4b65f49 --- /dev/null +++ b/src/components/ToggleSwitch.tsx @@ -0,0 +1,27 @@ +interface ToggleSwitchProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +export function ToggleSwitch({ checked, onChange, disabled = false }: ToggleSwitchProps) { + return ( + + ); +} diff --git a/src/components/ToggleSwitchRow.tsx b/src/components/ToggleSwitchRow.tsx new file mode 100644 index 0000000..c0f7ee5 --- /dev/null +++ b/src/components/ToggleSwitchRow.tsx @@ -0,0 +1,38 @@ +interface ToggleSwitchRowProps { + title: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +export function ToggleSwitchRow({ + title, + description, + checked, + onChange, + disabled = false, +}: ToggleSwitchRowProps) { + return ( +
+
+

{title}

+

{description}

+
+ +
+ ); +} diff --git a/src/components/ViewsSlider.tsx b/src/components/ViewsSlider.tsx new file mode 100644 index 0000000..081d47a --- /dev/null +++ b/src/components/ViewsSlider.tsx @@ -0,0 +1,39 @@ +interface ViewsSliderProps { + value: number; + onChange: (value: number) => void; +} + +export function ViewsSlider({ value, onChange }: ViewsSliderProps) { + const handleInputChange = (e: React.ChangeEvent) => { + const val = e.target.value; + if (val === '') { + onChange(1); + return; + } + const num = parseInt(val, 10); + if (!isNaN(num)) { + onChange(Math.min(999, Math.max(1, num))); + } + }; + + return ( +
+ onChange(parseInt(e.target.value))} + className="flex-1 h-2 bg-gray-200 dark:bg-dark-600 appearance-none cursor-pointer slider touch-manipulation" + /> + +
+ ); +} diff --git a/src/hooks/useCopyFeedback.ts b/src/hooks/useCopyFeedback.ts new file mode 100644 index 0000000..f6475df --- /dev/null +++ b/src/hooks/useCopyFeedback.ts @@ -0,0 +1,44 @@ +import { useCallback, useRef, useState } from 'react'; +import { copyToClipboard } from '../utils/clipboard'; + +export function useCopyFeedback(timeout = 2000) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | null>(null); + + const copy = useCallback( + async (text: string) => { + const success = await copyToClipboard(text); + if (success) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setCopied(true); + timeoutRef.current = setTimeout(() => setCopied(false), timeout); + } + return success; + }, + [timeout] + ); + + return { copied, copy }; +} + +export function useCopyFeedbackWithId(timeout = 2000) { + const [copiedId, setCopiedId] = useState(null); + const timeoutRef = useRef | null>(null); + + const copy = useCallback( + async (text: string, id: T) => { + const success = await copyToClipboard(text); + if (success) { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setCopiedId(id); + timeoutRef.current = setTimeout(() => setCopiedId(null), timeout); + } + return success; + }, + [timeout] + ); + + const isCopied = useCallback((id: T) => copiedId === id, [copiedId]); + + return { copiedId, copy, isCopied }; +} diff --git a/src/hooks/useModalState.ts b/src/hooks/useModalState.ts new file mode 100644 index 0000000..0e03c93 --- /dev/null +++ b/src/hooks/useModalState.ts @@ -0,0 +1,27 @@ +import { useCallback, useState } from 'react'; + +export function useModalState(initialState = false) { + const [isOpen, setIsOpen] = useState(initialState); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + + return { isOpen, open, close, toggle, setIsOpen }; +} + +export function useErrorModal() { + const [isOpen, setIsOpen] = useState(false); + const [message, setMessage] = useState(''); + + const showError = useCallback((errorMessage: string) => { + setMessage(errorMessage); + setIsOpen(true); + }, []); + + const close = useCallback(() => { + setIsOpen(false); + }, []); + + return { isOpen, message, showError, close }; +} diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts new file mode 100644 index 0000000..edde73c --- /dev/null +++ b/src/i18n/i18n.ts @@ -0,0 +1,44 @@ +import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initReactI18next } from 'react-i18next'; + +import daTranslations from './locales/da/da.json'; +import deTranslations from './locales/de/de.json'; +import enTranslations from './locales/en/en.json'; +import esTranslations from './locales/es/es.json'; +import frTranslations from './locales/fr/fr.json'; +import itTranslations from './locales/it/it.json'; +import nlTranslations from './locales/nl/nl.json'; +import noTranslations from './locales/no/no.json'; +import svTranslations from './locales/sv/sv.json'; +import zhTranslations from './locales/zh/zh.json'; + +i18n.use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'es', + debug: false, + detection: { + order: ['localStorage', 'navigator', 'htmlTag'], + caches: ['localStorage'], + lookupLocalStorage: 'paste-language', + }, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, + resources: { + en: { translations: enTranslations }, + es: { translations: esTranslations }, + de: { translations: deTranslations }, + fr: { translations: frTranslations }, + it: { translations: itTranslations }, + zh: { translations: zhTranslations }, + nl: { translations: nlTranslations }, + da: { translations: daTranslations }, + no: { translations: noTranslations }, + sv: { translations: svTranslations }, + }, + defaultNS: 'translations', + }); + +export default i18n; diff --git a/src/i18n/locales/da/da.json b/src/i18n/locales/da/da.json new file mode 100644 index 0000000..940853f --- /dev/null +++ b/src/i18n/locales/da/da.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Skabeloner", + "description": "Hurtig start med en skabelon", + "templates": { + "credentials": "Loginoplysninger", + "api_key": "API-nøgle", + "database": "Database", + "server": "Serveradgang", + "credit_card": "Betalingskort", + "email": "E-mail-konto" + } + }, + "editor": { + "tooltips": { + "copy_text": "Kopier som ren tekst", + "copy_html": "Kopier som HTML", + "copy_base64": "Kopier som Base64", + "bold": "Fed", + "italic": "Kursiv", + "strikethrough": "Gennemstreget", + "inline_code": "Indlejret kode", + "link": "Link", + "remove_link": "Fjern link", + "insert_password": "Indsæt adgangskode", + "paragraph": "Afsnit", + "heading1": "Overskrift 1", + "heading2": "Overskrift 2", + "heading3": "Overskrift 3", + "bullet_list": "Punktopstilling", + "numbered_list": "Nummereret liste", + "blockquote": "Citatblok", + "code_block": "Kodeblok", + "undo": "Fortryd", + "redo": "Annuller fortryd" + }, + "copy_success": { + "html": "HTML kopieret!", + "text": "Tekst kopieret!", + "base64": "Base64 kopieret!" + }, + "link_modal": { + "title": "Tilføj link", + "url_label": "URL", + "url_placeholder": "Indtast URL", + "cancel": "Annuller", + "update": "Opdater", + "insert": "Indsæt" + }, + "password_modal": { + "title": "Generer adgangskode", + "length_label": "Adgangskodelængde", + "options_label": "Muligheder", + "include_numbers": "Tal", + "include_symbols": "Symboler", + "include_uppercase": "Store bogstaver", + "include_lowercase": "Små bogstaver", + "generated_password": "Genereret adgangskode", + "refresh": "Opdater", + "cancel": "Annuller", + "insert": "Indsæt", + "copied_and_added": "Adgangskode tilføjet og kopieret til udklipsholder", + "added": "Adgangskode tilføjet" + }, + "formatting_tools": "Formateringsværktøjer", + "character_count": "tegn" + }, + "create_button": { + "creating_secret": "Opretter hemmelighed...", + "create": "Opret" + }, + "file_upload": { + "sign_in_to_upload": "Log ind for at uploade filer", + "sign_in": "Log ind", + "drop_files_here": "Slip filer her", + "drag_and_drop": "Træk og slip en fil, eller klik for at vælge", + "uploading": "Uploader...", + "upload_file": "Upload fil", + "file_too_large": "Filen \"{{fileName}}\" ({{fileSize}} MB) overstiger den maksimale størrelse på {{maxSize}} MB", + "max_size_exceeded": "Samlet filstørrelse overstiger maksimum på {{maxSize}} MB" + }, + "footer": { + "tagline": "Hemmelig, [heˈm(ɛ)li], betyder 'secret' på norsk", + "privacy": "Privatliv", + "terms": "Vilkår", + "api": "API", + "managed_hosting": "Managed Hosting", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Hjem", + "sign_in": "Log ind", + "sign_up": "Tilmeld", + "dashboard": "Oversigt", + "hero_text_part1": "Del hemmeligheder sikkert med krypterede beskeder, der automatisk", + "hero_text_part2": " selvdestruerer", + "hero_text_part3": " efter at være blevet læst." + }, + "dashboard_layout": { + "secrets": "Hemmeligheder", + "secret_requests": "Hemmelighedsanmodninger", + "account": "Konto", + "analytics": "Analyse", + "users": "Brugere", + "invites": "Invitationer", + "instance": "Instans", + "sign_out": "Log ud", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Hemmelighed oprettet!", + "secret_created_description": "Din hemmelighed er nu tilgængelig på følgende URL. Gem din dekrypteringsnøgle sikkert, da den ikke kan genskabes.", + "secret_url_label": "Hemmelig URL", + "decryption_key_label": "Dekrypteringsnøgle", + "password_label": "Adgangskode", + "create_new_secret_button": "Opret ny hemmelighed", + "copy_url_button": "Kopier URL", + "burn_secret_button": "Brænd hemmelighed", + "max_secrets_per_user_info": "Du kan oprette op til {{count}} hemmeligheder.", + "failed_to_burn": "Kunne ikke brænde hemmeligheden. Prøv venligst igen." + }, + "security_settings": { + "security_title": "Sikkerhed", + "security_description": "Konfigurer sikkerhedsindstillinger for din hemmelighed", + "remember_settings": "Husk", + "private_title": "Privat", + "private_description": "Private hemmeligheder er krypteret og kan kun ses med dekrypteringsnøglen og/eller adgangskode.", + "expiration_title": "Udløb", + "expiration_burn_after_time_description": "Indstil hvornår hemmeligheden skal destrueres", + "expiration_default_description": "Indstil hvor længe hemmeligheden skal være tilgængelig", + "max_views_title": "Maks visninger", + "burn_after_time_mode_title": "Brænd efter tid-tilstand", + "burn_after_time_mode_description": "Hemmeligheden vil blive destrueret, når tiden udløber, uanset hvor mange gange den er blevet vist.", + "password_protection_title": "Adgangskodebeskyttelse", + "password_protection_description": "Tilføj et ekstra lag af sikkerhed med en adgangskode", + "enter_password_label": "Indtast adgangskode", + "password_placeholder": "Indtast en sikker adgangskode...", + "password_hint": "Minimum 5 tegn. Modtagere skal bruge denne adgangskode for at se hemmeligheden", + "password_error": "Adgangskoden skal være på mindst 5 tegn", + "ip_restriction_title": "Begræns efter IP eller CIDR", + "ip_restriction_description": "CIDR-input giver brugere mulighed for at angive IP-adresseområder, der kan få adgang til hemmeligheden.", + "ip_address_cidr_label": "IP-adresse eller CIDR-område", + "ip_address_cidr_placeholder": "192.168.1.0/24 eller 203.0.113.5", + "ip_address_cidr_hint": "Kun anmodninger fra disse IP-adresser vil kunne få adgang til hemmeligheden", + "burn_after_time_title": "Brænd efter tiden udløber", + "burn_after_time_description": "Brænd kun hemmeligheden efter tiden udløber" + }, + "title_field": { + "placeholder": "Titel", + "hint": "Giv din hemmelighed en titel (valgfrit)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "visninger" + }, + "account_page": { + "title": "Kontoindstillinger", + "description": "Administrer dine kontopræferencer og sikkerhed", + "tabs": { + "profile": "Profil", + "security": "Sikkerhed", + "developer": "Udvikler", + "danger_zone": "Farezone" + }, + "profile_info": { + "title": "Profilinformation", + "description": "Opdater dine personlige oplysninger", + "first_name_label": "Fornavn", + "last_name_label": "Efternavn", + "username_label": "Brugernavn", + "email_label": "E-mailadresse", + "saving_button": "Gemmer...", + "save_changes_button": "Gem ændringer" + }, + "profile_settings": { + "username_taken": "Brugernavnet er allerede optaget" + }, + "security_settings": { + "title": "Sikkerhedsindstillinger", + "description": "Administrer din adgangskode og sikkerhedspræferencer", + "change_password_title": "Skift adgangskode", + "current_password_label": "Nuværende adgangskode", + "current_password_placeholder": "Indtast nuværende adgangskode", + "new_password_label": "Ny adgangskode", + "new_password_placeholder": "Indtast ny adgangskode", + "confirm_new_password_label": "Bekræft ny adgangskode", + "confirm_new_password_placeholder": "Bekræft ny adgangskode", + "password_mismatch_alert": "Nye adgangskoder matcher ikke", + "changing_password_button": "Ændrer...", + "change_password_button": "Skift adgangskode", + "password_change_success": "Adgangskoden blev ændret!", + "password_change_error": "Kunne ikke ændre adgangskode. Prøv venligst igen." + }, + "two_factor": { + "title": "Totrinsbekræftelse", + "description": "Tilføj et ekstra lag af sikkerhed til din konto", + "enabled": "Aktiveret", + "disabled": "Ikke aktiveret", + "setup_button": "Opsæt 2FA", + "disable_button": "Deaktiver 2FA", + "enter_password_to_enable": "Indtast din adgangskode for at aktivere totrinsbekræftelse.", + "continue": "Fortsæt", + "scan_qr_code": "Scan denne QR-kode med din autentificeringsapp (Google Authenticator, Authy, osv.).", + "manual_entry_hint": "Eller indtast denne kode manuelt i din autentificeringsapp:", + "enter_verification_code": "Indtast den 6-cifrede kode fra din autentificeringsapp for at bekræfte opsætningen.", + "verification_code": "Bekræftelseskode", + "verify_and_enable": "Bekræft og aktiver", + "back": "Tilbage", + "disable_title": "Deaktiver totrinsbekræftelse", + "disable_warning": "Deaktivering af 2FA vil gøre din konto mindre sikker. Du skal indtaste din adgangskode for at bekræfte.", + "invalid_password": "Ugyldig adgangskode. Prøv venligst igen.", + "invalid_code": "Ugyldig bekræftelseskode. Prøv venligst igen.", + "enable_error": "Kunne ikke aktivere 2FA. Prøv venligst igen.", + "verify_error": "Kunne ikke bekræfte 2FA-kode. Prøv venligst igen.", + "disable_error": "Kunne ikke deaktivere 2FA. Prøv venligst igen.", + "backup_codes_title": "Sikkerhedskoder", + "backup_codes_description": "Gem disse sikkerhedskoder et sikkert sted. Du kan bruge dem til at få adgang til din konto, hvis du mister adgangen til din autentificeringsapp.", + "backup_codes_warning": "Hver kode kan kun bruges én gang. Gem dem sikkert!", + "backup_codes_saved": "Jeg har gemt mine sikkerhedskoder" + }, + "danger_zone": { + "title": "Farezone", + "description": "Irreversible og destruktive handlinger", + "delete_account_title": "Slet konto", + "delete_account_description": "Når du sletter din konto, er der ingen vej tilbage. Dette vil permanent slette din konto, alle dine hemmeligheder, og fjerne alle tilknyttede data. Denne handling kan ikke fortrydes.", + "delete_account_bullet1": "Alle dine hemmeligheder vil blive permanent slettet", + "delete_account_bullet2": "Dine kontodata vil blive fjernet fra vores servere", + "delete_account_bullet3": "Alle delte hemmelighedslinks vil blive ugyldige", + "delete_account_bullet4": "Denne handling kan ikke omgøres", + "delete_account_confirm": "Er du sikker på, at du vil slette din konto? Denne handling kan ikke fortrydes.", + "delete_account_button": "Slet konto", + "deleting_account_button": "Sletter konto..." + }, + "developer": { + "title": "API-nøgler", + "description": "Administrer API-nøgler til programmatisk adgang", + "create_key": "Opret nøgle", + "create_key_title": "Opret API-nøgle", + "key_name": "Nøglenavn", + "key_name_placeholder": "f.eks., Min integration", + "expiration": "Udløb", + "never_expires": "Udløber aldrig", + "expires_30_days": "30 dage", + "expires_90_days": "90 dage", + "expires_1_year": "1 år", + "create_button": "Opret", + "name_required": "Nøglenavn er påkrævet", + "create_error": "Kunne ikke oprette API-nøgle", + "key_created": "API-nøgle oprettet!", + "key_warning": "Kopier denne nøgle nu. Du vil ikke kunne se den igen.", + "dismiss": "Jeg har kopieret nøglen", + "no_keys": "Ingen API-nøgler endnu. Opret en for at komme i gang.", + "created": "Oprettet", + "last_used": "Sidst brugt", + "expires": "Udløber", + "docs_hint": "Lær hvordan du bruger API-nøgler i", + "api_docs": "API-dokumentationen" + } + }, + "analytics_page": { + "title": "Analyse", + "description": "Spor din hemmelighedsdelingsaktivitet og indsigt", + "time_range": { + "last_7_days": "Sidste 7 dage", + "last_14_days": "Sidste 14 dage", + "last_30_days": "Sidste 30 dage" + }, + "total_secrets": "Samlet antal hemmeligheder", + "from_last_period": "+{{percentage}}% fra sidste periode", + "total_views": "Samlet antal visninger", + "avg_views_per_secret": "Gns. visninger/hemmelighed", + "active_secrets": "Aktive hemmeligheder", + "daily_activity": { + "title": "Daglig aktivitet", + "description": "Hemmeligheder oprettet og visninger over tid", + "secrets": "Hemmeligheder", + "views": "Visninger", + "secrets_created": "Hemmeligheder oprettet", + "secret_views": "Hemmelighedsvisninger", + "date": "Dato", + "trend": "Ændring", + "vs_previous": "vs forrige dag", + "no_data": "Ingen aktivitetsdata tilgængelige endnu." + }, + "locale": "da-DK", + "top_countries": { + "title": "Toplande", + "description": "Hvor dine hemmeligheder bliver vist", + "views": "visninger" + }, + "secret_types": { + "title": "Hemmelighedstyper", + "description": "Fordeling efter beskyttelsesniveau", + "password_protected": "Adgangskodebeskyttet", + "ip_restricted": "IP-begrænset", + "burn_after_time": "Brænd efter tid" + }, + "expiration_stats": { + "title": "Udløbsstatistik", + "description": "Hvor længe hemmeligheder typisk varer", + "one_hour": "1 time", + "one_day": "1 dag", + "one_week_plus": "1 uge+" + }, + "visitor_analytics": { + "title": "Besøgsanalyse", + "description": "Sidevisninger og unikke besøgende", + "unique": "Unikke", + "views": "Visninger", + "date": "Dato", + "trend": "Ændring", + "vs_previous": "vs forrige dag", + "no_data": "Ingen besøgsdata tilgængelige endnu." + }, + "secret_requests": { + "total": "Hemmelighedsanmodninger", + "fulfilled": "Opfyldte anmodninger" + }, + "loading": "Indlæser analyse...", + "no_permission": "Du har ikke tilladelse til at se analyser.", + "failed_to_fetch": "Kunne ikke hente analysedata." + }, + "instance_page": { + "title": "Instansindstillinger", + "description": "Konfigurer din Hemmelig-instans", + "managed_mode": { + "title": "Administreret tilstand", + "description": "Denne instans administreres via miljøvariabler. Indstillinger er skrivebeskyttede." + }, + "tabs": { + "general": "Generelt", + "security": "Sikkerhed", + "organization": "Organisation", + "webhook": "Webhooks", + "metrics": "Metrikker" + }, + "system_status": { + "title": "Systemstatus", + "description": "Instanssundhed og ydeevnemetrikker", + "version": "Version", + "uptime": "Oppetid", + "memory": "Hukommelse", + "cpu_usage": "CPU-forbrug" + }, + "general_settings": { + "title": "Generelle indstillinger", + "description": "Grundlæggende instanskonfiguration", + "instance_name_label": "Instansnavn", + "logo_label": "Instanslogo", + "logo_upload": "Upload logo", + "logo_remove": "Fjern logo", + "logo_hint": "PNG, JPEG, GIF, SVG eller WebP. Maks 512KB.", + "logo_alt": "Instanslogo", + "logo_invalid_type": "Ugyldig filtype. Upload venligst et PNG-, JPEG-, GIF-, SVG- eller WebP-billede.", + "logo_too_large": "Filen er for stor. Maksimal størrelse er 512KB.", + "default_expiration_label": "Standard hemmelighedsudløb", + "max_secrets_per_user_label": "Maks hemmeligheder pr. bruger", + "max_secret_size_label": "Maks hemmelighedsstørrelse (MB)", + "instance_description_label": "Instansbeskrivelse", + "important_message_label": "Vigtig besked", + "important_message_placeholder": "Indtast en vigtig besked, der skal vises til alle brugere...", + "important_message_hint": "Denne besked vil blive vist som et advarselsbanner på hjemmesiden. Understøtter markdown-formatering. Lad stå tomt for at skjule.", + "allow_registration_title": "Tillad registrering", + "allow_registration_description": "Tillad nye brugere at registrere sig", + "email_verification_title": "E-mailbekræftelse", + "email_verification_description": "Kræv e-mailbekræftelse" + }, + "saving_button": "Gemmer...", + "save_settings_button": "Gem indstillinger", + "security_settings": { + "title": "Sikkerhedsindstillinger", + "description": "Konfigurer sikkerhed og adgangskontroller", + "rate_limiting_title": "Hastighedsbegrænsning", + "rate_limiting_description": "Aktiver anmodningshastighedsbegrænsning", + "max_password_attempts_label": "Maks adgangskodeforsøg", + "session_timeout_label": "Sessionstimeout (timer)", + "allow_file_uploads_title": "Tillad filupload", + "allow_file_uploads_description": "Tillad brugere at vedhæfte filer til hemmeligheder" + }, + "email_settings": { + "title": "E-mailindstillinger", + "description": "Konfigurer SMTP og e-mailnotifikationer", + "smtp_host_label": "SMTP-vært", + "smtp_port_label": "SMTP-port", + "username_label": "Brugernavn", + "password_label": "Adgangskode" + }, + "database_info": { + "title": "Databaseinformation", + "description": "Databasestatus og statistikker", + "stats_title": "Databasestatistik", + "total_secrets": "Samlet antal hemmeligheder:", + "total_users": "Samlet antal brugere:", + "disk_usage": "Diskforbrug:", + "connection_status_title": "Forbindelsesstatus", + "connected": "Forbundet", + "connected_description": "Databasen er sund og svarer normalt" + }, + "system_info": { + "title": "Systeminformation", + "description": "Serverdetaljer og vedligeholdelse", + "system_info_title": "Systeminfo", + "version": "Version:", + "uptime": "Oppetid:", + "status": "Status:", + "resource_usage_title": "Ressourceforbrug", + "memory": "Hukommelse:", + "cpu": "CPU:", + "disk": "Disk:" + }, + "maintenance_actions": { + "title": "Vedligeholdelseshandlinger", + "description": "Disse handlinger kan påvirke systemets tilgængelighed. Brug med forsigtighed.", + "restart_service_button": "Genstart service", + "clear_cache_button": "Ryd cache", + "export_logs_button": "Eksporter logs" + } + }, + "secrets_page": { + "title": "Dine hemmeligheder", + "description": "Administrer og overvåg dine delte hemmeligheder", + "create_secret_button": "Opret hemmelighed", + "search_placeholder": "Søg i hemmeligheder...", + "filter": { + "all_secrets": "Alle hemmeligheder", + "active": "Aktive", + "expired": "Udløbne" + }, + "total_secrets": "Samlet antal hemmeligheder", + "active_secrets": "Aktive", + "expired_secrets": "Udløbne", + "no_secrets_found_title": "Ingen hemmeligheder fundet", + "no_secrets_found_description_filter": "Prøv at justere dine søge- eller filterkriterier.", + "no_secrets_found_description_empty": "Opret din første hemmelighed for at komme i gang.", + "password_protected": "Adgangskode", + "files": "filer", + "table": { + "secret_header": "Hemmelighed", + "created_header": "Oprettet", + "status_header": "Status", + "views_header": "Visninger", + "actions_header": "Handlinger", + "untitled_secret": "Navnløs hemmelighed", + "expired_status": "Udløbet", + "active_status": "Aktiv", + "never_expires": "Udløber aldrig", + "expired_time": "Udløbet", + "views_left": "visninger tilbage", + "copy_url_tooltip": "Kopier URL", + "open_secret_tooltip": "Åbn hemmelighed", + "delete_secret_tooltip": "Slet hemmelighed", + "delete_confirmation_title": "Er du sikker?", + "delete_confirmation_text": "Denne handling kan ikke fortrydes. Dette vil permanent slette hemmeligheden.", + "delete_confirm_button": "Ja, slet den", + "delete_cancel_button": "Annuller" + } + }, + "users_page": { + "title": "Brugeradministration", + "description": "Administrer brugere og deres tilladelser", + "add_user_button": "Tilføj bruger", + "search_placeholder": "Søg efter brugere...", + "filter": { + "all_roles": "Alle roller", + "admin": "Admin", + "user": "Bruger", + "all_status": "Alle statusser", + "active": "Aktiv", + "suspended": "Suspenderet", + "pending": "Afventer" + }, + "total_users": "Samlet antal brugere", + "active_users": "Aktive", + "admins": "Administratorer", + "pending_users": "Afventer", + "no_users_found_title": "Ingen brugere fundet", + "no_users_found_description_filter": "Prøv at justere dine søge- eller filterkriterier.", + "no_users_found_description_empty": "Ingen brugere er blevet tilføjet endnu.", + "table": { + "user_header": "Bruger", + "role_header": "Rolle", + "status_header": "Status", + "activity_header": "Aktivitet", + "last_login_header": "Sidste login", + "actions_header": "Handlinger", + "created_at": "Oprettet den" + }, + "status": { + "active": "Aktiv", + "banned": "Bandlyst" + }, + "delete_user_modal": { + "title": "Slet bruger", + "confirmation_message": "Er du sikker på, at du vil slette brugeren {{username}}? Denne handling kan ikke fortrydes.", + "confirm_button": "Slet", + "cancel_button": "Annuller" + }, + "edit_user_modal": { + "title": "Rediger bruger: {{username}}", + "username_label": "Brugernavn", + "email_label": "E-mail", + "role_label": "Rolle", + "banned_label": "Bandlyst", + "save_button": "Gem", + "cancel_button": "Annuller" + }, + "add_user_modal": { + "title": "Tilføj ny bruger", + "name_label": "Navn", + "username_label": "Brugernavn", + "email_label": "E-mail", + "password_label": "Adgangskode", + "role_label": "Rolle", + "save_button": "Tilføj bruger", + "cancel_button": "Annuller" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Tilbage til login", + "check_email_title": "Tjek din e-mail", + "check_email_description": "Vi har sendt et link til nulstilling af adgangskode til {{email}}", + "did_not_receive_email": "Modtog du ikke e-mailen? Tjek din spam-mappe eller prøv igen.", + "try_again_button": "Prøv igen", + "forgot_password_title": "Glemt adgangskode?", + "forgot_password_description": "Ingen bekymringer, vi sender dig instruktioner til nulstilling", + "email_label": "E-mail", + "email_placeholder": "Indtast din e-mail", + "email_hint": "Indtast e-mailen knyttet til din konto", + "sending_button": "Sender...", + "reset_password_button": "Nulstil adgangskode", + "remember_password": "Husker du din adgangskode?", + "sign_in_link": "Log ind", + "unexpected_error": "Der opstod en uventet fejl. Prøv venligst igen." + }, + "login_page": { + "back_to_hemmelig": "Tilbage til Hemmelig", + "welcome_back": "Velkommen tilbage til Hemmelig", + "welcome_back_title": "Velkommen tilbage", + "welcome_back_description": "Log ind på din Hemmelig-konto", + "username_label": "Brugernavn", + "username_placeholder": "Indtast dit brugernavn", + "password_label": "Adgangskode", + "password_placeholder": "Indtast din adgangskode", + "forgot_password_link": "Glemt din adgangskode?", + "signing_in_button": "Logger ind...", + "sign_in_button": "Log ind", + "or_continue_with": "Eller fortsæt med", + "continue_with_github": "Fortsæt med GitHub", + "no_account_question": "Har du ikke en konto?", + "sign_up_link": "Tilmeld", + "unexpected_error": "Der opstod en uventet fejl. Prøv venligst igen." + }, + "register_page": { + "back_to_hemmelig": "Tilbage til Hemmelig", + "join_hemmelig": "Tilmeld dig Hemmelig for at dele hemmeligheder sikkert", + "email_password_disabled_message": "Registrering med e-mail og adgangskode er deaktiveret. Brug venligst en af de sociale login-muligheder nedenfor.", + "create_account_title": "Opret konto", + "create_account_description": "Tilmeld dig Hemmelig for at dele hemmeligheder sikkert", + "username_label": "Brugernavn", + "username_placeholder": "Vælg et brugernavn", + "email_label": "E-mail", + "email_placeholder": "Indtast din e-mail", + "password_label": "Adgangskode", + "password_placeholder": "Opret en adgangskode", + "password_strength_label": "Adgangskodestyrke", + "password_strength_levels": { + "very_weak": "Meget svag", + "weak": "Svag", + "fair": "Middel", + "good": "God", + "strong": "Stærk" + }, + "confirm_password_label": "Bekræft adgangskode", + "confirm_password_placeholder": "Bekræft din adgangskode", + "passwords_match": "Adgangskoderne matcher", + "passwords_do_not_match": "Adgangskoderne matcher ikke", + "password_mismatch_alert": "Adgangskoderne matcher ikke", + "creating_account_button": "Opretter konto...", + "create_account_button": "Opret konto", + "or_continue_with": "Eller fortsæt med", + "continue_with_github": "Fortsæt med GitHub", + "already_have_account_question": "Har du allerede en konto?", + "sign_in_link": "Log ind", + "invite_code_label": "Invitationskode", + "invite_code_placeholder": "Indtast din invitationskode", + "invite_code_required": "Invitationskode er påkrævet", + "invalid_invite_code": "Ugyldig invitationskode", + "failed_to_validate_invite": "Kunne ikke validere invitationskode", + "unexpected_error": "Der opstod en uventet fejl. Prøv venligst igen.", + "email_domain_not_allowed": "E-mail-domæne ikke tilladt", + "account_already_exists": "En konto med denne e-mail eksisterer allerede. Log venligst ind i stedet." + }, + "secret_form": { + "failed_to_create_secret": "Kunne ikke oprette hemmelighed: {{errorMessage}}", + "failed_to_upload_file": "Kunne ikke uploade fil: {{fileName}}" + }, + "secret_page": { + "password_label": "Adgangskode", + "password_placeholder": "Indtast adgangskode for at se hemmelighed", + "decryption_key_label": "Dekrypteringsnøgle", + "decryption_key_placeholder": "Indtast dekrypteringsnøglen", + "view_secret_button": "Se hemmelighed", + "views_remaining_tooltip": "Visninger tilbage: {{count}}", + "loading_message": "Dekrypterer hemmelighed...", + "files_title": "Vedhæftede filer", + "secret_waiting_title": "Nogen delte en hemmelighed med dig", + "secret_waiting_description": "Denne hemmelighed er krypteret og kan kun ses, når du klikker på knappen nedenfor.", + "one_view_remaining": "Denne hemmelighed kan kun ses 1 gang mere", + "views_remaining": "Denne hemmelighed kan ses {{count}} gange mere", + "view_warning": "Når den er vist, kan denne handling ikke fortrydes", + "secret_revealed": "Hemmelighed", + "copy_secret": "Kopier til udklipsholder", + "download": "Download", + "create_your_own": "Opret din egen hemmelighed", + "encrypted_secret": "Krypteret hemmelighed", + "unlock_secret": "Lås hemmelighed op", + "delete_secret": "Slet hemmelighed", + "delete_modal_title": "Slet hemmelighed", + "delete_modal_message": "Er du sikker på, at du vil slette denne hemmelighed? Denne handling kan ikke fortrydes.", + "decryption_failed": "Kunne ikke dekryptere hemmeligheden. Tjek venligst din adgangskode eller dekrypteringsnøgle.", + "fetch_error": "Der opstod en fejl ved hentning af hemmeligheden. Prøv venligst igen." + }, + "expiration": { + "28_days": "28 dage", + "14_days": "14 dage", + "7_days": "7 dage", + "3_days": "3 dage", + "1_day": "1 dag", + "12_hours": "12 timer", + "4_hours": "4 timer", + "1_hour": "1 time", + "30_minutes": "30 minutter", + "5_minutes": "5 minutter" + }, + "error_display": { + "clear_errors_button_title": "Ryd fejl" + }, + "secret_not_found_page": { + "title": "Hemmelighed ikke fundet", + "message": "Hemmeligheden du leder efter eksisterer ikke, er udløbet, eller er blevet brændt.", + "error_details": "Fejldetaljer:", + "go_home_button": "Gå til hjemmesiden" + }, + "organization_page": { + "title": "Organisationsindstillinger", + "description": "Konfigurer organisationsomfattende indstillinger og adgangskontroller", + "registration_settings": { + "title": "Registreringsindstillinger", + "description": "Kontroller hvordan brugere kan deltage i din organisation", + "invite_only_title": "Kun invitationsregistrering", + "invite_only_description": "Brugere kan kun registrere sig med en gyldig invitationskode", + "require_registered_user_title": "Kun registrerede brugere", + "require_registered_user_description": "Kun registrerede brugere kan oprette hemmeligheder", + "disable_email_password_signup_title": "Deaktiver e-mail/adgangskode registrering", + "disable_email_password_signup_description": "Deaktiver registrering med e-mail og adgangskode (kun social login)", + "allowed_domains_title": "Tilladte e-mail-domæner", + "allowed_domains_description": "Tillad kun registrering fra specifikke e-mail-domæner (komma-separeret, f.eks., firma.com, org.net)", + "allowed_domains_placeholder": "firma.com, org.net", + "allowed_domains_hint": "Komma-separeret liste over e-mail-domæner. Lad stå tomt for at tillade alle domæner." + }, + "invite_codes": { + "title": "Invitationskoder", + "description": "Opret og administrer invitationskoder for nye brugere", + "create_invite_button": "Opret invitationskode", + "code_header": "Kode", + "uses_header": "Brug", + "expires_header": "Udløber", + "actions_header": "Handlinger", + "unlimited": "Ubegrænset", + "never": "Aldrig", + "expired": "Udløbet", + "no_invites": "Ingen invitationskoder endnu", + "no_invites_description": "Opret en invitationskode for at tillade nye brugere at registrere sig", + "copy_tooltip": "Kopier kode", + "delete_tooltip": "Slet kode" + }, + "create_invite_modal": { + "title": "Opret invitationskode", + "max_uses_label": "Maks brug", + "max_uses_placeholder": "Lad stå tomt for ubegrænset", + "expiration_label": "Udløbsdato", + "expiration_options": { + "never": "Aldrig", + "24_hours": "24 timer", + "7_days": "7 dage", + "30_days": "30 dage" + }, + "cancel_button": "Annuller", + "create_button": "Opret" + }, + "saving_button": "Gemmer...", + "save_settings_button": "Gem indstillinger" + }, + "invites_page": { + "title": "Invitationskoder", + "description": "Administrer invitationskoder for nye brugerregistreringer", + "create_invite_button": "Opret invitation", + "loading": "Indlæser invitationskoder...", + "table": { + "code_header": "Kode", + "uses_header": "Brug", + "expires_header": "Udløber", + "status_header": "Status", + "never": "Aldrig" + }, + "status": { + "active": "Aktiv", + "expired": "Udløbet", + "used": "Brugt", + "inactive": "Inaktiv" + }, + "no_invites": "Ingen invitationskoder endnu", + "create_modal": { + "title": "Opret invitationskode", + "max_uses_label": "Maksimal brug", + "expires_in_label": "Udløber om (dage)" + }, + "delete_modal": { + "title": "Deaktiver invitationskode", + "confirm_text": "Deaktiver", + "cancel_text": "Annuller", + "message": "Er du sikker på, at du vil deaktivere invitationskode {{code}}? Denne handling kan ikke fortrydes." + }, + "toast": { + "created": "Invitationskode oprettet", + "deactivated": "Invitationskode deaktiveret", + "copied": "Invitationskode kopieret til udklipsholder", + "fetch_error": "Kunne ikke hente invitationskoder", + "create_error": "Kunne ikke oprette invitationskode", + "delete_error": "Kunne ikke deaktivere invitationskode" + } + }, + "social_login": { + "continue_with": "Fortsæt med {{provider}}", + "sign_up_with": "Tilmeld med {{provider}}" + }, + "setup_page": { + "title": "Velkommen til Hemmelig", + "description": "Opret din admin-konto for at komme i gang", + "name_label": "Fulde navn", + "name_placeholder": "Indtast dit fulde navn", + "username_label": "Brugernavn", + "username_placeholder": "Vælg et brugernavn", + "email_label": "E-mailadresse", + "email_placeholder": "Indtast din e-mail", + "password_label": "Adgangskode", + "password_placeholder": "Opret en adgangskode (min 8 tegn)", + "confirm_password_label": "Bekræft adgangskode", + "confirm_password_placeholder": "Bekræft din adgangskode", + "create_admin": "Opret admin-konto", + "creating": "Opretter konto...", + "success": "Admin-konto oprettet succesfuldt! Log venligst ind.", + "error": "Kunne ikke oprette admin-konto", + "passwords_mismatch": "Adgangskoderne matcher ikke", + "password_too_short": "Adgangskoden skal være på mindst 8 tegn", + "note": "Denne opsætning kan kun gennemføres én gang. Admin-kontoen vil have fuld adgang til at administrere denne instans." + }, + "theme_toggle": { + "switch_to_light": "Skift til lyst tema", + "switch_to_dark": "Skift til mørkt tema" + }, + "webhook_settings": { + "title": "Webhook-notifikationer", + "description": "Underret eksterne tjenester, når hemmeligheder vises eller brændes", + "enable_webhooks_title": "Aktiver webhooks", + "enable_webhooks_description": "Send HTTP POST-anmodninger til din webhook-URL, når begivenheder indtræffer", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://eksempel.dk/webhook", + "webhook_url_hint": "URL'en, hvor webhook-payloads vil blive sendt", + "webhook_secret_label": "Webhook-hemmelighed", + "webhook_secret_placeholder": "Indtast en hemmelighed til HMAC-signering", + "webhook_secret_hint": "Bruges til at signere webhook-payloads med HMAC-SHA256. Signaturen sendes i X-Hemmelig-Signature-headeren.", + "events_title": "Webhook-begivenheder", + "on_view_title": "Hemmelighed vist", + "on_view_description": "Send en webhook, når en hemmelighed vises", + "on_burn_title": "Hemmelighed brændt", + "on_burn_description": "Send en webhook, når en hemmelighed brændes eller slettes" + }, + "metrics_settings": { + "title": "Prometheus-metrikker", + "description": "Eksponer metrikker til overvågning med Prometheus", + "enable_metrics_title": "Aktiver Prometheus-metrikker", + "enable_metrics_description": "Eksponer et /api/metrics endepunkt til Prometheus-skrabning", + "metrics_secret_label": "Metrik-hemmelighed", + "metrics_secret_placeholder": "Indtast en hemmelighed til autentificering", + "metrics_secret_hint": "Bruges som et Bearer-token til at autentificere anmodninger til metrik-endepunktet. Lad stå tomt for ingen autentificering (ikke anbefalet).", + "endpoint_info_title": "Endepunktsinformation", + "endpoint_info_description": "Når aktiveret, vil metrikker være tilgængelige på:", + "endpoint_auth_hint": "Inkluder hemmeligheden som et Bearer-token i Authorization-headeren, når du henter metrikker." + }, + "verify_2fa_page": { + "back_to_login": "Tilbage til login", + "title": "Totrinsbekræftelse", + "description": "Indtast den 6-cifrede kode fra din autentificeringsapp", + "enter_code_hint": "Indtast koden fra din autentificeringsapp", + "verifying": "Bekræfter...", + "verify_button": "Bekræft", + "invalid_code": "Ugyldig bekreftelseskode. Prøv venligst igen.", + "unexpected_error": "Der opstod en uventet fejl. Prøv venligst igen." + }, + "common": { + "error": "Fejl", + "cancel": "Annuller", + "confirm": "Bekræft", + "ok": "OK", + "delete": "Slet", + "deleting": "Sletter...", + "loading": "Indlæser..." + }, + "pagination": { + "showing": "Viser {{start}} til {{end}} af {{total}} resultater", + "previous_page": "Forrige side", + "next_page": "Næste side" + }, + "not_found_page": { + "title": "Side ikke fundet", + "message": "Denne side er forsvundet i den blå luft, ligesom vores hemmeligheder gør.", + "hint": "Siden du leder efter eksisterer ikke eller er blevet flyttet.", + "go_home_button": "Gå hjem", + "create_secret_button": "Opret hemmelighed" + }, + "error_boundary": { + "title": "Noget gik galt", + "message": "Der opstod en uventet fejl under behandlingen af din anmodning.", + "hint": "Bare rolig, dine hemmeligheder er stadig sikre. Prøv at opdatere siden.", + "error_details": "Fejldetaljer:", + "unknown_error": "Der opstod en ukendt fejl", + "try_again_button": "Prøv igen", + "go_home_button": "Gå hjem" + }, + "secret_requests_page": { + "title": "Hemmelighedsanmodninger", + "description": "Anmod om hemmeligheder fra andre via sikre links", + "create_request_button": "Opret anmodning", + "no_requests": "Ingen hemmelighedsanmodninger endnu. Opret en for at komme i gang.", + "table": { + "title_header": "Titel", + "status_header": "Status", + "secret_expiry_header": "Hemmelighed udløber", + "link_expires_header": "Link udløber", + "copy_link_tooltip": "Kopier opretterlink", + "view_secret_tooltip": "Vis hemmelighed", + "cancel_tooltip": "Annuller anmodning" + }, + "status": { + "pending": "Afventer", + "fulfilled": "Opfyldt", + "expired": "Udløbet", + "cancelled": "Annulleret" + }, + "time": { + "days": "{{count}} dag", + "days_plural": "{{count}} dage", + "hours": "{{count}} time", + "hours_plural": "{{count}} timer", + "minutes": "{{count}} minut", + "minutes_plural": "{{count}} minutter" + }, + "link_modal": { + "title": "Opretterlink", + "description": "Send dette link til den person, der skal levere hemmeligheden. De vil kunne indtaste og kryptere hemmeligheden via dette link.", + "copy_button": "Kopier link", + "close_button": "Luk", + "warning": "Dette link kan kun bruges én gang. Når en hemmelighed er indsendt, vil linket ikke længere virke." + }, + "cancel_modal": { + "title": "Annuller anmodning", + "message": "Er du sikker på, at du vil annullere anmodningen \"{{title}}\"? Denne handling kan ikke fortrydes.", + "confirm_text": "Annuller anmodning", + "cancel_text": "Behold anmodning" + }, + "toast": { + "copied": "Link kopieret til udklipsholder", + "cancelled": "Anmodning annulleret", + "fetch_error": "Kunne ikke hente anmodningsdetaljer", + "cancel_error": "Kunne ikke annullere anmodning" + } + }, + "create_request_page": { + "title": "Opret hemmelighedsanmodning", + "description": "Anmod om en hemmelighed fra nogen ved at generere et sikkert link, de kan bruge til at indsende den", + "back_button": "Tilbage til hemmelighedsanmodninger", + "form": { + "title_label": "Anmodningstitel", + "title_placeholder": "f.eks. AWS-legitimationsoplysninger til Projekt X", + "description_label": "Beskrivelse (valgfrit)", + "description_placeholder": "Giv yderligere kontekst om hvad du har brug for...", + "link_validity_label": "Linkgyldighed", + "link_validity_hint": "Hvor længe opretterlinket forbliver aktivt", + "secret_settings_title": "Hemmelighedsindstillinger", + "secret_settings_description": "Disse indstillinger vil gælde for hemmeligheden, når den er oprettet", + "secret_expiration_label": "Hemmelighed udløber", + "max_views_label": "Maksimale visninger", + "password_label": "Adgangskodebeskyttelse (valgfrit)", + "password_placeholder": "Indtast en adgangskode (min 5 tegn)", + "password_hint": "Modtagere skal bruge denne adgangskode for at se hemmeligheden", + "ip_restriction_label": "IP-begrænsning (valgfrit)", + "ip_restriction_placeholder": "192.168.1.0/24 eller 203.0.113.5", + "prevent_burn_label": "Forhindre auto-sletning (behold hemmelighed efter max visninger)", + "webhook_title": "Webhook-notifikation (valgfrit)", + "webhook_description": "Modtag en notifikation, når hemmeligheden indsendes", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://din-server.com/webhook", + "webhook_url_hint": "HTTPS anbefales. En notifikation sendes, når hemmeligheden oprettes.", + "creating_button": "Opretter...", + "create_button": "Opret anmodning" + }, + "validity": { + "30_days": "30 dage", + "14_days": "14 dage", + "7_days": "7 dage", + "3_days": "3 dage", + "1_day": "1 dag", + "12_hours": "12 timer", + "1_hour": "1 time" + }, + "success": { + "title": "Anmodning oprettet!", + "description": "Del opretterlinket med den person, der skal levere hemmeligheden", + "creator_link_label": "Opretterlink", + "webhook_secret_label": "Webhook-hemmelighed", + "webhook_secret_warning": "Gem denne hemmelighed nu! Den vises ikke igen. Brug den til at verificere webhook-signaturer.", + "expires_at": "Link udløber: {{date}}", + "create_another_button": "Opret endnu en anmodning", + "view_all_button": "Se alle anmodninger" + }, + "toast": { + "created": "Hemmelighedsanmodning oprettet", + "create_error": "Kunne ikke oprette hemmelighedsanmodning", + "copied": "Kopieret til udklipsholder" + } + }, + "request_secret_page": { + "loading": "Indlæser anmodning...", + "error": { + "title": "Anmodning ikke tilgængelig", + "invalid_link": "Dette link er ugyldigt eller er blevet ændret.", + "not_found": "Denne anmodning blev ikke fundet, eller linket er ugyldigt.", + "already_fulfilled": "Denne anmodning er allerede opfyldt eller udløbet.", + "generic": "Der opstod en fejl under indlæsning af anmodningen.", + "go_home_button": "Gå til forsiden" + }, + "form": { + "title": "Indsend en hemmelighed", + "description": "Nogen har bedt dig om at dele en hemmelighed med dem sikkert", + "password_protected_note": "Denne hemmelighed vil være adgangskodebeskyttet", + "encryption_note": "Din hemmelighed krypteres i din browser, før den sendes. Dekrypteringsnøglen inkluderes kun i den endelige URL, du deler.", + "submitting_button": "Krypterer og indsender...", + "submit_button": "Indsend hemmelighed" + }, + "success": { + "title": "Hemmelighed oprettet!", + "description": "Din hemmelighed er blevet krypteret og gemt sikkert", + "decryption_key_label": "Dekrypteringsnøgle", + "warning": "Vigtigt: Kopier denne dekrypteringsnøgle nu og send den til anmoderen. Dette er den eneste gang, du vil se den!", + "manual_send_note": "Du skal manuelt sende denne dekrypteringsnøgle til den person, der anmodede om hemmeligheden. De har allerede hemmelighedens URL i deres dashboard.", + "create_own_button": "Opret din egen hemmelighed" + }, + "toast": { + "created": "Hemmelighed indsendt", + "create_error": "Kunne ikke indsende hemmelighed", + "copied": "Kopieret til udklipsholder" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/de/de.json b/src/i18n/locales/de/de.json new file mode 100644 index 0000000..f0f6f61 --- /dev/null +++ b/src/i18n/locales/de/de.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Vorlagen", + "description": "Schnellstart mit einer Vorlage", + "templates": { + "credentials": "Anmeldedaten", + "api_key": "API-Schlüssel", + "database": "Datenbank", + "server": "Serverzugang", + "credit_card": "Zahlungskarte", + "email": "E-Mail-Konto" + } + }, + "editor": { + "tooltips": { + "copy_text": "Als Klartext kopieren", + "copy_html": "Als HTML kopieren", + "copy_base64": "Als Base64 kopieren", + "bold": "Fett", + "italic": "Kursiv", + "strikethrough": "Durchgestrichen", + "inline_code": "Inline-Code", + "link": "Link", + "remove_link": "Link entfernen", + "insert_password": "Passwort einfügen", + "paragraph": "Absatz", + "heading1": "Überschrift 1", + "heading2": "Überschrift 2", + "heading3": "Überschrift 3", + "bullet_list": "Aufzählungsliste", + "numbered_list": "Nummerierte Liste", + "blockquote": "Zitat", + "code_block": "Codeblock", + "undo": "Rückgängig", + "redo": "Wiederherstellen" + }, + "copy_success": { + "html": "HTML kopiert!", + "text": "Text kopiert!", + "base64": "Base64 kopiert!" + }, + "link_modal": { + "title": "Link hinzufügen", + "url_label": "URL", + "url_placeholder": "URL eingeben", + "cancel": "Abbrechen", + "update": "Aktualisieren", + "insert": "Einfügen" + }, + "password_modal": { + "title": "Passwort generieren", + "length_label": "Passwortlänge", + "options_label": "Optionen", + "include_numbers": "Zahlen", + "include_symbols": "Symbole", + "include_uppercase": "Großbuchstaben", + "include_lowercase": "Kleinbuchstaben", + "generated_password": "Generiertes Passwort", + "refresh": "Aktualisieren", + "cancel": "Abbrechen", + "insert": "Einfügen", + "copied_and_added": "Passwort hinzugefügt und in Zwischenablage kopiert", + "added": "Passwort hinzugefügt" + }, + "formatting_tools": "Formatierungswerkzeuge", + "character_count": "Zeichen" + }, + "create_button": { + "creating_secret": "Geheimnis wird erstellt...", + "create": "Erstellen" + }, + "file_upload": { + "sign_in_to_upload": "Anmelden, um Dateien hochzuladen", + "sign_in": "Anmelden", + "drop_files_here": "Dateien hier ablegen", + "drag_and_drop": "Datei hierher ziehen oder klicken, um eine Datei auszuwählen", + "uploading": "Wird hochgeladen...", + "upload_file": "Datei hochladen", + "file_too_large": "Datei \"{{fileName}}\" ({{fileSize}} MB) überschreitet die maximale Größe von {{maxSize}} MB", + "max_size_exceeded": "Gesamtdateigröße überschreitet das Maximum von {{maxSize}} MB" + }, + "footer": { + "tagline": "Hemmelig, [heˈm(ɛ)li], bedeutet 'Geheimnis' auf Norwegisch", + "privacy": "Datenschutz", + "terms": "AGB", + "api": "API", + "managed_hosting": "Managed Hosting", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Startseite", + "sign_in": "Anmelden", + "sign_up": "Registrieren", + "dashboard": "Dashboard", + "hero_text_part1": "Teilen Sie Geheimnisse sicher mit verschlüsselten Nachrichten, die sich automatisch", + "hero_text_part2": " selbst zerstören", + "hero_text_part3": " nachdem sie gelesen wurden." + }, + "dashboard_layout": { + "secrets": "Geheimnisse", + "secret_requests": "Geheimnis-Anfragen", + "account": "Konto", + "analytics": "Analysen", + "users": "Benutzer", + "invites": "Einladungen", + "instance": "Instanz", + "sign_out": "Abmelden", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Geheimnis erstellt!", + "secret_created_description": "Ihr Geheimnis ist jetzt unter der folgenden URL verfügbar. Bewahren Sie Ihren Entschlüsselungsschlüssel sicher auf, da er nicht wiederhergestellt werden kann.", + "secret_url_label": "Geheimnis-URL", + "decryption_key_label": "Entschlüsselungsschlüssel", + "password_label": "Passwort", + "create_new_secret_button": "Neues Geheimnis erstellen", + "copy_url_button": "URL kopieren", + "burn_secret_button": "Geheimnis vernichten", + "max_secrets_per_user_info": "Sie können bis zu {{count}} Geheimnisse erstellen.", + "failed_to_burn": "Geheimnis konnte nicht vernichtet werden. Bitte versuchen Sie es erneut." + }, + "security_settings": { + "security_title": "Sicherheit", + "security_description": "Sicherheitseinstellungen für Ihr Geheimnis konfigurieren", + "remember_settings": "Merken", + "private_title": "Privat", + "private_description": "Private Geheimnisse sind verschlüsselt und können nur mit dem Entschlüsselungsschlüssel und/oder Passwort angezeigt werden.", + "expiration_title": "Ablauf", + "expiration_burn_after_time_description": "Legen Sie fest, wann das Geheimnis zerstört werden soll", + "expiration_default_description": "Legen Sie fest, wie lange das Geheimnis verfügbar sein soll", + "max_views_title": "Maximale Aufrufe", + "burn_after_time_mode_title": "Zeitgesteuerte Vernichtung", + "burn_after_time_mode_description": "Das Geheimnis wird nach Ablauf der Zeit zerstört, unabhängig davon, wie oft es angesehen wurde.", + "password_protection_title": "Passwortschutz", + "password_protection_description": "Fügen Sie eine zusätzliche Sicherheitsebene mit einem Passwort hinzu", + "enter_password_label": "Passwort eingeben", + "password_placeholder": "Geben Sie ein sicheres Passwort ein...", + "password_hint": "Mindestens 5 Zeichen. Empfänger benötigen dieses Passwort, um das Geheimnis anzuzeigen", + "password_error": "Das Passwort muss mindestens 5 Zeichen lang sein", + "ip_restriction_title": "Nach IP oder CIDR einschränken", + "ip_restriction_description": "Die CIDR-Eingabe ermöglicht es Benutzern, IP-Adressbereiche anzugeben, die auf das Geheimnis zugreifen können.", + "ip_address_cidr_label": "IP-Adresse oder CIDR-Bereich", + "ip_address_cidr_placeholder": "192.168.1.0/24 oder 203.0.113.5", + "ip_address_cidr_hint": "Nur Anfragen von diesen IP-Adressen können auf das Geheimnis zugreifen", + "burn_after_time_title": "Nach Zeitablauf vernichten", + "burn_after_time_description": "Das Geheimnis erst nach Ablauf der Zeit vernichten" + }, + "title_field": { + "placeholder": "Titel", + "hint": "Geben Sie Ihrem Geheimnis einen einprägsamen Titel (optional)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "Aufrufe" + }, + "account_page": { + "title": "Kontoeinstellungen", + "description": "Verwalten Sie Ihre Kontoeinstellungen und Sicherheit", + "tabs": { + "profile": "Profil", + "security": "Sicherheit", + "developer": "Entwickler", + "danger_zone": "Gefahrenzone" + }, + "profile_info": { + "title": "Profilinformationen", + "description": "Aktualisieren Sie Ihre persönlichen Informationen", + "first_name_label": "Vorname", + "last_name_label": "Nachname", + "username_label": "Benutzername", + "email_label": "E-Mail-Adresse", + "saving_button": "Wird gespeichert...", + "save_changes_button": "Änderungen speichern" + }, + "profile_settings": { + "username_taken": "Benutzername ist bereits vergeben" + }, + "security_settings": { + "title": "Sicherheitseinstellungen", + "description": "Verwalten Sie Ihr Passwort und Sicherheitseinstellungen", + "change_password_title": "Passwort ändern", + "current_password_label": "Aktuelles Passwort", + "current_password_placeholder": "Aktuelles Passwort eingeben", + "new_password_label": "Neues Passwort", + "new_password_placeholder": "Neues Passwort eingeben", + "confirm_new_password_label": "Neues Passwort bestätigen", + "confirm_new_password_placeholder": "Neues Passwort bestätigen", + "password_mismatch_alert": "Die neuen Passwörter stimmen nicht überein", + "changing_password_button": "Wird geändert...", + "change_password_button": "Passwort ändern", + "password_change_success": "Passwort erfolgreich geändert!", + "password_change_error": "Passwort konnte nicht geändert werden. Bitte versuchen Sie es erneut." + }, + "two_factor": { + "title": "Zwei-Faktor-Authentifizierung", + "description": "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu", + "enabled": "Aktiviert", + "disabled": "Nicht aktiviert", + "setup_button": "2FA einrichten", + "disable_button": "2FA deaktivieren", + "enter_password_to_enable": "Geben Sie Ihr Passwort ein, um die Zwei-Faktor-Authentifizierung zu aktivieren.", + "continue": "Weiter", + "scan_qr_code": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App (Google Authenticator, Authy, etc.).", + "manual_entry_hint": "Oder geben Sie diesen Code manuell in Ihre Authenticator-App ein:", + "enter_verification_code": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein, um die Einrichtung zu bestätigen.", + "verification_code": "Bestätigungscode", + "verify_and_enable": "Bestätigen & Aktivieren", + "back": "Zurück", + "disable_title": "Zwei-Faktor-Authentifizierung deaktivieren", + "disable_warning": "Das Deaktivieren von 2FA macht Ihr Konto weniger sicher. Sie müssen Ihr Passwort eingeben, um zu bestätigen.", + "invalid_password": "Ungültiges Passwort. Bitte versuchen Sie es erneut.", + "invalid_code": "Ungültiger Bestätigungscode. Bitte versuchen Sie es erneut.", + "enable_error": "2FA konnte nicht aktiviert werden. Bitte versuchen Sie es erneut.", + "verify_error": "2FA-Code konnte nicht verifiziert werden. Bitte versuchen Sie es erneut.", + "disable_error": "2FA konnte nicht deaktiviert werden. Bitte versuchen Sie es erneut.", + "backup_codes_title": "Backup-Codes", + "backup_codes_description": "Speichern Sie diese Backup-Codes an einem sicheren Ort. Sie können sie verwenden, um auf Ihr Konto zuzugreifen, wenn Sie den Zugang zu Ihrer Authenticator-App verlieren.", + "backup_codes_warning": "Jeder Code kann nur einmal verwendet werden. Bewahren Sie sie sicher auf!", + "backup_codes_saved": "Ich habe meine Backup-Codes gespeichert" + }, + "danger_zone": { + "title": "Gefahrenzone", + "description": "Irreversible und destruktive Aktionen", + "delete_account_title": "Konto löschen", + "delete_account_description": "Sobald Sie Ihr Konto löschen, gibt es kein Zurück. Dies löscht dauerhaft Ihr Konto, alle Ihre Geheimnisse und entfernt alle zugehörigen Daten. Diese Aktion kann nicht rückgängig gemacht werden.", + "delete_account_bullet1": "Alle Ihre Geheimnisse werden dauerhaft gelöscht", + "delete_account_bullet2": "Ihre Kontodaten werden von unseren Servern entfernt", + "delete_account_bullet3": "Alle geteilten Geheimnis-Links werden ungültig", + "delete_account_bullet4": "Diese Aktion kann nicht rückgängig gemacht werden", + "delete_account_confirm": "Sind Sie sicher, dass Sie Ihr Konto löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "delete_account_button": "Konto löschen", + "deleting_account_button": "Konto wird gelöscht..." + }, + "developer": { + "title": "API-Schlüssel", + "description": "API-Schlüssel für programmatischen Zugriff verwalten", + "create_key": "Schlüssel erstellen", + "create_key_title": "API-Schlüssel erstellen", + "key_name": "Schlüsselname", + "key_name_placeholder": "z.B. Meine Integration", + "expiration": "Ablauf", + "never_expires": "Läuft nie ab", + "expires_30_days": "30 Tage", + "expires_90_days": "90 Tage", + "expires_1_year": "1 Jahr", + "create_button": "Erstellen", + "name_required": "Schlüsselname ist erforderlich", + "create_error": "API-Schlüssel konnte nicht erstellt werden", + "key_created": "API-Schlüssel erstellt!", + "key_warning": "Kopieren Sie diesen Schlüssel jetzt. Sie können ihn nicht erneut sehen.", + "dismiss": "Ich habe den Schlüssel kopiert", + "no_keys": "Noch keine API-Schlüssel. Erstellen Sie einen, um loszulegen.", + "created": "Erstellt", + "last_used": "Zuletzt verwendet", + "expires": "Läuft ab", + "docs_hint": "Erfahren Sie, wie Sie API-Schlüssel verwenden in der", + "api_docs": "API-Dokumentation" + } + }, + "analytics_page": { + "title": "Analysen", + "description": "Verfolgen Sie Ihre Geheimnis-Sharing-Aktivitäten und Einblicke", + "time_range": { + "last_7_days": "Letzte 7 Tage", + "last_14_days": "Letzte 14 Tage", + "last_30_days": "Letzte 30 Tage" + }, + "total_secrets": "Geheimnisse gesamt", + "from_last_period": "+{{percentage}}% seit letztem Zeitraum", + "total_views": "Aufrufe gesamt", + "avg_views_per_secret": "Durchschn. Aufrufe/Geheimnis", + "active_secrets": "Aktive Geheimnisse", + "daily_activity": { + "title": "Tägliche Aktivität", + "description": "Erstellte Geheimnisse und Aufrufe im Zeitverlauf", + "secrets": "Geheimnisse", + "views": "Aufrufe", + "secrets_created": "Erstellte Geheimnisse", + "secret_views": "Geheimnis-Aufrufe", + "date": "Datum", + "trend": "Änderung", + "vs_previous": "ggü. Vortag", + "no_data": "Noch keine Aktivitätsdaten verfügbar." + }, + "locale": "de-DE", + "top_countries": { + "title": "Top-Länder", + "description": "Wo Ihre Geheimnisse angesehen werden", + "views": "Aufrufe" + }, + "secret_types": { + "title": "Geheimnis-Typen", + "description": "Verteilung nach Schutzstufe", + "password_protected": "Passwortgeschützt", + "ip_restricted": "IP-beschränkt", + "burn_after_time": "Zeitgesteuerte Vernichtung" + }, + "expiration_stats": { + "title": "Ablaufstatistiken", + "description": "Wie lange Geheimnisse typischerweise bestehen", + "one_hour": "1 Stunde", + "one_day": "1 Tag", + "one_week_plus": "1 Woche+" + }, + "visitor_analytics": { + "title": "Besucheranalysen", + "description": "Seitenaufrufe und eindeutige Besucher", + "unique": "Eindeutig", + "views": "Aufrufe", + "date": "Datum", + "trend": "Änderung", + "vs_previous": "ggü. Vortag", + "no_data": "Noch keine Besucherdaten verfügbar." + }, + "secret_requests": { + "total": "Geheimnis-Anfragen", + "fulfilled": "Erfüllte Anfragen" + }, + "loading": "Analysen werden geladen...", + "no_permission": "Sie haben keine Berechtigung, Analysen anzuzeigen.", + "failed_to_fetch": "Analysedaten konnten nicht abgerufen werden." + }, + "instance_page": { + "title": "Instanzeinstellungen", + "description": "Konfigurieren Sie Ihre Hemmelig-Instanz", + "managed_mode": { + "title": "Verwalteter Modus", + "description": "Diese Instanz wird über Umgebungsvariablen verwaltet. Einstellungen sind schreibgeschützt." + }, + "tabs": { + "general": "Allgemein", + "security": "Sicherheit", + "organization": "Organisation", + "webhook": "Webhooks", + "metrics": "Metriken" + }, + "system_status": { + "title": "Systemstatus", + "description": "Instanzgesundheit und Leistungsmetriken", + "version": "Version", + "uptime": "Betriebszeit", + "memory": "Speicher", + "cpu_usage": "CPU-Auslastung" + }, + "general_settings": { + "title": "Allgemeine Einstellungen", + "description": "Grundlegende Instanzkonfiguration", + "instance_name_label": "Instanzname", + "logo_label": "Instanz-Logo", + "logo_upload": "Logo hochladen", + "logo_remove": "Logo entfernen", + "logo_hint": "PNG, JPEG, GIF, SVG oder WebP. Max. 512KB.", + "logo_alt": "Instanz-Logo", + "logo_invalid_type": "Ungültiger Dateityp. Bitte laden Sie ein PNG-, JPEG-, GIF-, SVG- oder WebP-Bild hoch.", + "logo_too_large": "Datei ist zu groß. Maximale Größe ist 512KB.", + "default_expiration_label": "Standard-Ablaufzeit", + "max_secrets_per_user_label": "Max. Geheimnisse pro Benutzer", + "max_secret_size_label": "Max. Geheimisgröße (MB)", + "instance_description_label": "Instanzbeschreibung", + "important_message_label": "Wichtige Nachricht", + "important_message_placeholder": "Geben Sie eine wichtige Nachricht ein, die allen Benutzern angezeigt wird...", + "important_message_hint": "Diese Nachricht wird als Warnbanner auf der Startseite angezeigt. Unterstützt Markdown-Formatierung. Leer lassen zum Ausblenden.", + "allow_registration_title": "Registrierung erlauben", + "allow_registration_description": "Neuen Benutzern die Registrierung erlauben", + "email_verification_title": "E-Mail-Verifizierung", + "email_verification_description": "E-Mail-Verifizierung erforderlich" + }, + "saving_button": "Wird gespeichert...", + "save_settings_button": "Einstellungen speichern", + "security_settings": { + "title": "Sicherheitseinstellungen", + "description": "Sicherheits- und Zugriffskontrollen konfigurieren", + "rate_limiting_title": "Ratenbegrenzung", + "rate_limiting_description": "Anfragenratenbegrenzung aktivieren", + "max_password_attempts_label": "Max. Passwortversuche", + "session_timeout_label": "Sitzungstimeout (Stunden)", + "allow_file_uploads_title": "Datei-Uploads erlauben", + "allow_file_uploads_description": "Benutzern erlauben, Dateien an Geheimnisse anzuhängen" + }, + "email_settings": { + "title": "E-Mail-Einstellungen", + "description": "SMTP und E-Mail-Benachrichtigungen konfigurieren", + "smtp_host_label": "SMTP-Host", + "smtp_port_label": "SMTP-Port", + "username_label": "Benutzername", + "password_label": "Passwort" + }, + "database_info": { + "title": "Datenbankinformationen", + "description": "Datenbankstatus und Statistiken", + "stats_title": "Datenbankstatistiken", + "total_secrets": "Geheimnisse gesamt:", + "total_users": "Benutzer gesamt:", + "disk_usage": "Speichernutzung:", + "connection_status_title": "Verbindungsstatus", + "connected": "Verbunden", + "connected_description": "Datenbank ist gesund und antwortet normal" + }, + "system_info": { + "title": "Systeminformationen", + "description": "Serverdetails und Wartung", + "system_info_title": "Systeminfo", + "version": "Version:", + "uptime": "Betriebszeit:", + "status": "Status:", + "resource_usage_title": "Ressourcennutzung", + "memory": "Speicher:", + "cpu": "CPU:", + "disk": "Festplatte:" + }, + "maintenance_actions": { + "title": "Wartungsaktionen", + "description": "Diese Aktionen können die Systemverfügbarkeit beeinträchtigen. Mit Vorsicht verwenden.", + "restart_service_button": "Dienst neu starten", + "clear_cache_button": "Cache leeren", + "export_logs_button": "Protokolle exportieren" + } + }, + "secrets_page": { + "title": "Ihre Geheimnisse", + "description": "Verwalten und überwachen Sie Ihre geteilten Geheimnisse", + "create_secret_button": "Geheimnis erstellen", + "search_placeholder": "Geheimnisse suchen...", + "filter": { + "all_secrets": "Alle Geheimnisse", + "active": "Aktiv", + "expired": "Abgelaufen" + }, + "total_secrets": "Geheimnisse gesamt", + "active_secrets": "Aktiv", + "expired_secrets": "Abgelaufen", + "no_secrets_found_title": "Keine Geheimnisse gefunden", + "no_secrets_found_description_filter": "Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen.", + "no_secrets_found_description_empty": "Erstellen Sie Ihr erstes Geheimnis, um zu beginnen.", + "password_protected": "Passwort", + "files": "Dateien", + "table": { + "secret_header": "Geheimnis", + "created_header": "Erstellt", + "status_header": "Status", + "views_header": "Aufrufe", + "actions_header": "Aktionen", + "untitled_secret": "Unbenanntes Geheimnis", + "expired_status": "Abgelaufen", + "active_status": "Aktiv", + "never_expires": "Läuft nie ab", + "expired_time": "Abgelaufen", + "views_left": "Aufrufe übrig", + "copy_url_tooltip": "URL kopieren", + "open_secret_tooltip": "Geheimnis öffnen", + "delete_secret_tooltip": "Geheimnis löschen", + "delete_confirmation_title": "Sind Sie sicher?", + "delete_confirmation_text": "Diese Aktion kann nicht rückgängig gemacht werden. Das Geheimnis wird dauerhaft gelöscht.", + "delete_confirm_button": "Ja, löschen", + "delete_cancel_button": "Abbrechen" + } + }, + "users_page": { + "title": "Benutzerverwaltung", + "description": "Benutzer und deren Berechtigungen verwalten", + "add_user_button": "Benutzer hinzufügen", + "search_placeholder": "Benutzer suchen...", + "filter": { + "all_roles": "Alle Rollen", + "admin": "Administrator", + "user": "Benutzer", + "all_status": "Alle Status", + "active": "Aktiv", + "suspended": "Gesperrt", + "pending": "Ausstehend" + }, + "total_users": "Benutzer gesamt", + "active_users": "Aktiv", + "admins": "Administratoren", + "pending_users": "Ausstehend", + "no_users_found_title": "Keine Benutzer gefunden", + "no_users_found_description_filter": "Versuchen Sie, Ihre Such- oder Filterkriterien anzupassen.", + "no_users_found_description_empty": "Es wurden noch keine Benutzer hinzugefügt.", + "table": { + "user_header": "Benutzer", + "role_header": "Rolle", + "status_header": "Status", + "activity_header": "Aktivität", + "last_login_header": "Letzte Anmeldung", + "actions_header": "Aktionen", + "created_at": "Erstellt am" + }, + "status": { + "active": "Aktiv", + "banned": "Gesperrt" + }, + "delete_user_modal": { + "title": "Benutzer löschen", + "confirmation_message": "Sind Sie sicher, dass Sie den Benutzer {{username}} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm_button": "Löschen", + "cancel_button": "Abbrechen" + }, + "edit_user_modal": { + "title": "Benutzer bearbeiten: {{username}}", + "username_label": "Benutzername", + "email_label": "E-Mail", + "role_label": "Rolle", + "banned_label": "Gesperrt", + "save_button": "Speichern", + "cancel_button": "Abbrechen" + }, + "add_user_modal": { + "title": "Neuen Benutzer hinzufügen", + "name_label": "Name", + "username_label": "Benutzername", + "email_label": "E-Mail", + "password_label": "Passwort", + "role_label": "Rolle", + "save_button": "Benutzer hinzufügen", + "cancel_button": "Abbrechen" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Zurück zur Anmeldung", + "check_email_title": "Überprüfen Sie Ihre E-Mail", + "check_email_description": "Wir haben einen Link zum Zurücksetzen des Passworts an {{email}} gesendet", + "did_not_receive_email": "E-Mail nicht erhalten? Überprüfen Sie Ihren Spam-Ordner oder versuchen Sie es erneut.", + "try_again_button": "Erneut versuchen", + "forgot_password_title": "Passwort vergessen?", + "forgot_password_description": "Keine Sorge, wir senden Ihnen Anweisungen zum Zurücksetzen", + "email_label": "E-Mail", + "email_placeholder": "Geben Sie Ihre E-Mail ein", + "email_hint": "Geben Sie die E-Mail ein, die mit Ihrem Konto verknüpft ist", + "sending_button": "Wird gesendet...", + "reset_password_button": "Passwort zurücksetzen", + "remember_password": "Erinnern Sie sich an Ihr Passwort?", + "sign_in_link": "Anmelden", + "unexpected_error": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut." + }, + "login_page": { + "back_to_hemmelig": "Zurück zu Hemmelig", + "welcome_back": "Willkommen zurück bei Hemmelig", + "welcome_back_title": "Willkommen zurück", + "welcome_back_description": "Melden Sie sich bei Ihrem Hemmelig-Konto an", + "username_label": "Benutzername", + "username_placeholder": "Geben Sie Ihren Benutzernamen ein", + "password_label": "Passwort", + "password_placeholder": "Geben Sie Ihr Passwort ein", + "forgot_password_link": "Passwort vergessen?", + "signing_in_button": "Wird angemeldet...", + "sign_in_button": "Anmelden", + "or_continue_with": "Oder fortfahren mit", + "continue_with_github": "Mit GitHub fortfahren", + "no_account_question": "Noch kein Konto?", + "sign_up_link": "Registrieren", + "unexpected_error": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut." + }, + "register_page": { + "back_to_hemmelig": "Zurück zu Hemmelig", + "join_hemmelig": "Treten Sie Hemmelig bei, um Geheimnisse sicher zu teilen", + "email_password_disabled_message": "Die Registrierung mit E-Mail und Passwort ist deaktiviert. Bitte verwenden Sie eine der unten stehenden Social-Login-Optionen.", + "create_account_title": "Konto erstellen", + "create_account_description": "Treten Sie Hemmelig bei, um Geheimnisse sicher zu teilen", + "username_label": "Benutzername", + "username_placeholder": "Wählen Sie einen Benutzernamen", + "email_label": "E-Mail", + "email_placeholder": "Geben Sie Ihre E-Mail ein", + "password_label": "Passwort", + "password_placeholder": "Erstellen Sie ein Passwort", + "password_strength_label": "Passwortstärke", + "password_strength_levels": { + "very_weak": "Sehr schwach", + "weak": "Schwach", + "fair": "Mittel", + "good": "Gut", + "strong": "Stark" + }, + "confirm_password_label": "Passwort bestätigen", + "confirm_password_placeholder": "Bestätigen Sie Ihr Passwort", + "passwords_match": "Passwörter stimmen überein", + "passwords_do_not_match": "Passwörter stimmen nicht überein", + "password_mismatch_alert": "Passwörter stimmen nicht überein", + "creating_account_button": "Konto wird erstellt...", + "create_account_button": "Konto erstellen", + "or_continue_with": "Oder fortfahren mit", + "continue_with_github": "Mit GitHub fortfahren", + "already_have_account_question": "Bereits ein Konto?", + "sign_in_link": "Anmelden", + "invite_code_label": "Einladungscode", + "invite_code_placeholder": "Geben Sie Ihren Einladungscode ein", + "invite_code_required": "Einladungscode ist erforderlich", + "invalid_invite_code": "Ungültiger Einladungscode", + "failed_to_validate_invite": "Einladungscode konnte nicht validiert werden", + "unexpected_error": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", + "email_domain_not_allowed": "E-Mail-Domain nicht erlaubt", + "account_already_exists": "Ein Konto mit dieser E-Mail existiert bereits. Bitte melden Sie sich stattdessen an." + }, + "secret_form": { + "failed_to_create_secret": "Geheimnis konnte nicht erstellt werden: {{errorMessage}}", + "failed_to_upload_file": "Datei konnte nicht hochgeladen werden: {{fileName}}" + }, + "secret_page": { + "password_label": "Passwort", + "password_placeholder": "Passwort eingeben, um das Geheimnis anzuzeigen", + "decryption_key_label": "Entschlüsselungsschlüssel", + "decryption_key_placeholder": "Entschlüsselungsschlüssel eingeben", + "view_secret_button": "Geheimnis anzeigen", + "views_remaining_tooltip": "Verbleibende Aufrufe: {{count}}", + "loading_message": "Geheimnis wird entschlüsselt...", + "files_title": "Angehängte Dateien", + "secret_waiting_title": "Jemand hat ein Geheimnis mit Ihnen geteilt", + "secret_waiting_description": "Dieses Geheimnis ist verschlüsselt und kann erst angezeigt werden, wenn Sie auf die Schaltfläche unten klicken.", + "one_view_remaining": "Dieses Geheimnis kann nur noch 1 Mal angezeigt werden", + "views_remaining": "Dieses Geheimnis kann noch {{count}} Mal angezeigt werden", + "view_warning": "Einmal angezeigt, kann diese Aktion nicht rückgängig gemacht werden", + "secret_revealed": "Geheimnis", + "copy_secret": "In Zwischenablage kopieren", + "download": "Herunterladen", + "create_your_own": "Erstellen Sie Ihr eigenes Geheimnis", + "encrypted_secret": "Verschlüsseltes Geheimnis", + "unlock_secret": "Geheimnis entsperren", + "delete_secret": "Geheimnis löschen", + "delete_modal_title": "Geheimnis löschen", + "delete_modal_message": "Sind Sie sicher, dass Sie dieses Geheimnis löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "decryption_failed": "Das Geheimnis konnte nicht entschlüsselt werden. Bitte überprüfen Sie Ihr Passwort oder den Entschlüsselungsschlüssel.", + "fetch_error": "Beim Abrufen des Geheimnisses ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut." + }, + "expiration": { + "28_days": "28 Tage", + "14_days": "14 Tage", + "7_days": "7 Tage", + "3_days": "3 Tage", + "1_day": "1 Tag", + "12_hours": "12 Stunden", + "4_hours": "4 Stunden", + "1_hour": "1 Stunde", + "30_minutes": "30 Minuten", + "5_minutes": "5 Minuten" + }, + "error_display": { + "clear_errors_button_title": "Fehler löschen" + }, + "secret_not_found_page": { + "title": "Geheimnis nicht gefunden", + "message": "Das Geheimnis, das Sie suchen, existiert nicht, ist abgelaufen oder wurde vernichtet.", + "error_details": "Fehlerdetails:", + "go_home_button": "Zur Startseite" + }, + "organization_page": { + "title": "Organisationseinstellungen", + "description": "Organisationsweite Einstellungen und Zugriffskontrollen konfigurieren", + "registration_settings": { + "title": "Registrierungseinstellungen", + "description": "Steuern Sie, wie Benutzer Ihrer Organisation beitreten können", + "invite_only_title": "Nur mit Einladung", + "invite_only_description": "Benutzer können sich nur mit einem gültigen Einladungscode registrieren", + "require_registered_user_title": "Nur registrierte Benutzer", + "require_registered_user_description": "Nur registrierte Benutzer können Geheimnisse erstellen", + "disable_email_password_signup_title": "E-Mail/Passwort-Registrierung deaktivieren", + "disable_email_password_signup_description": "Registrierung mit E-Mail und Passwort deaktivieren (nur Social Login)", + "allowed_domains_title": "Erlaubte E-Mail-Domains", + "allowed_domains_description": "Nur Registrierung von bestimmten E-Mail-Domains erlauben (kommagetrennt, z.B. firma.de, org.net)", + "allowed_domains_placeholder": "firma.de, org.net", + "allowed_domains_hint": "Kommagetrennte Liste von E-Mail-Domains. Leer lassen, um alle Domains zu erlauben." + }, + "invite_codes": { + "title": "Einladungscodes", + "description": "Einladungscodes für neue Benutzer erstellen und verwalten", + "create_invite_button": "Einladungscode erstellen", + "code_header": "Code", + "uses_header": "Verwendungen", + "expires_header": "Läuft ab", + "actions_header": "Aktionen", + "unlimited": "Unbegrenzt", + "never": "Nie", + "expired": "Abgelaufen", + "no_invites": "Noch keine Einladungscodes", + "no_invites_description": "Erstellen Sie einen Einladungscode, um neuen Benutzern die Registrierung zu ermöglichen", + "copy_tooltip": "Code kopieren", + "delete_tooltip": "Code löschen" + }, + "create_invite_modal": { + "title": "Einladungscode erstellen", + "max_uses_label": "Maximale Verwendungen", + "max_uses_placeholder": "Leer lassen für unbegrenzt", + "expiration_label": "Ablauf", + "expiration_options": { + "never": "Nie", + "24_hours": "24 Stunden", + "7_days": "7 Tage", + "30_days": "30 Tage" + }, + "cancel_button": "Abbrechen", + "create_button": "Erstellen" + }, + "saving_button": "Wird gespeichert...", + "save_settings_button": "Einstellungen speichern" + }, + "invites_page": { + "title": "Einladungscodes", + "description": "Einladungscodes für die Registrierung neuer Benutzer verwalten", + "create_invite_button": "Einladung erstellen", + "loading": "Einladungscodes werden geladen...", + "table": { + "code_header": "Code", + "uses_header": "Verwendungen", + "expires_header": "Läuft ab", + "status_header": "Status", + "never": "Nie" + }, + "status": { + "active": "Aktiv", + "expired": "Abgelaufen", + "used": "Verwendet", + "inactive": "Inaktiv" + }, + "no_invites": "Noch keine Einladungscodes", + "create_modal": { + "title": "Einladungscode erstellen", + "max_uses_label": "Maximale Verwendungen", + "expires_in_label": "Läuft ab in (Tage)" + }, + "delete_modal": { + "title": "Einladungscode deaktivieren", + "confirm_text": "Deaktivieren", + "cancel_text": "Abbrechen", + "message": "Sind Sie sicher, dass Sie den Einladungscode {{code}} deaktivieren möchten? Diese Aktion kann nicht rückgängig gemacht werden." + }, + "toast": { + "created": "Einladungscode erstellt", + "deactivated": "Einladungscode deaktiviert", + "copied": "Einladungscode in Zwischenablage kopiert", + "fetch_error": "Einladungscodes konnten nicht abgerufen werden", + "create_error": "Einladungscode konnte nicht erstellt werden", + "delete_error": "Einladungscode konnte nicht deaktiviert werden" + } + }, + "social_login": { + "continue_with": "Fortfahren mit {{provider}}", + "sign_up_with": "Registrieren mit {{provider}}" + }, + "setup_page": { + "title": "Willkommen bei Hemmelig", + "description": "Erstellen Sie Ihr Administratorkonto, um zu beginnen", + "name_label": "Vollständiger Name", + "name_placeholder": "Geben Sie Ihren vollständigen Namen ein", + "username_label": "Benutzername", + "username_placeholder": "Wählen Sie einen Benutzernamen", + "email_label": "E-Mail-Adresse", + "email_placeholder": "Geben Sie Ihre E-Mail ein", + "password_label": "Passwort", + "password_placeholder": "Erstellen Sie ein Passwort (mind. 8 Zeichen)", + "confirm_password_label": "Passwort bestätigen", + "confirm_password_placeholder": "Bestätigen Sie Ihr Passwort", + "create_admin": "Administratorkonto erstellen", + "creating": "Konto wird erstellt...", + "success": "Administratorkonto erfolgreich erstellt! Bitte melden Sie sich an.", + "error": "Administratorkonto konnte nicht erstellt werden", + "passwords_mismatch": "Passwörter stimmen nicht überein", + "password_too_short": "Passwort muss mindestens 8 Zeichen lang sein", + "note": "Diese Einrichtung kann nur einmal durchgeführt werden. Das Administratorkonto hat vollen Zugriff, um diese Instanz zu verwalten." + }, + "theme_toggle": { + "switch_to_light": "Zum hellen Modus wechseln", + "switch_to_dark": "Zum dunklen Modus wechseln" + }, + "webhook_settings": { + "title": "Webhook-Benachrichtigungen", + "description": "Externe Dienste benachrichtigen, wenn Geheimnisse angesehen oder vernichtet werden", + "enable_webhooks_title": "Webhooks aktivieren", + "enable_webhooks_description": "HTTP-POST-Anfragen an Ihre Webhook-URL senden, wenn Ereignisse auftreten", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://beispiel.de/webhook", + "webhook_url_hint": "Die URL, an die Webhook-Payloads gesendet werden", + "webhook_secret_label": "Webhook-Geheimnis", + "webhook_secret_placeholder": "Geben Sie ein Geheimnis für die HMAC-Signierung ein", + "webhook_secret_hint": "Wird verwendet, um Webhook-Payloads mit HMAC-SHA256 zu signieren. Die Signatur wird im X-Hemmelig-Signature-Header gesendet.", + "events_title": "Webhook-Ereignisse", + "on_view_title": "Geheimnis angesehen", + "on_view_description": "Webhook senden, wenn ein Geheimnis angesehen wird", + "on_burn_title": "Geheimnis vernichtet", + "on_burn_description": "Webhook senden, wenn ein Geheimnis vernichtet oder gelöscht wird" + }, + "metrics_settings": { + "title": "Prometheus-Metriken", + "description": "Metriken für die Überwachung mit Prometheus bereitstellen", + "enable_metrics_title": "Prometheus-Metriken aktivieren", + "enable_metrics_description": "Einen /api/metrics-Endpunkt für Prometheus-Scraping bereitstellen", + "metrics_secret_label": "Metriken-Geheimnis", + "metrics_secret_placeholder": "Geben Sie ein Geheimnis für die Authentifizierung ein", + "metrics_secret_hint": "Wird als Bearer-Token verwendet, um Anfragen an den Metriken-Endpunkt zu authentifizieren. Leer lassen für keine Authentifizierung (nicht empfohlen).", + "endpoint_info_title": "Endpunkt-Informationen", + "endpoint_info_description": "Nach der Aktivierung sind Metriken verfügbar unter:", + "endpoint_auth_hint": "Fügen Sie das Geheimnis als Bearer-Token im Authorization-Header hinzu, wenn Sie Metriken abrufen." + }, + "verify_2fa_page": { + "back_to_login": "Zurück zur Anmeldung", + "title": "Zwei-Faktor-Authentifizierung", + "description": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein", + "enter_code_hint": "Geben Sie den Code aus Ihrer Authenticator-App ein", + "verifying": "Wird überprüft...", + "verify_button": "Bestätigen", + "invalid_code": "Ungültiger Bestätigungscode. Bitte versuchen Sie es erneut.", + "unexpected_error": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut." + }, + "common": { + "error": "Fehler", + "cancel": "Abbrechen", + "confirm": "Bestätigen", + "ok": "OK", + "delete": "Löschen", + "deleting": "Wird gelöscht...", + "loading": "Wird geladen..." + }, + "pagination": { + "showing": "Zeige {{start}} bis {{end}} von {{total}} Ergebnissen", + "previous_page": "Vorherige Seite", + "next_page": "Nächste Seite" + }, + "not_found_page": { + "title": "Seite nicht gefunden", + "message": "Diese Seite ist verschwunden, genau wie unsere Geheimnisse es tun.", + "hint": "Die gesuchte Seite existiert nicht oder wurde verschoben.", + "go_home_button": "Zur Startseite", + "create_secret_button": "Geheimnis erstellen" + }, + "error_boundary": { + "title": "Etwas ist schiefgelaufen", + "message": "Bei der Verarbeitung Ihrer Anfrage ist ein unerwarteter Fehler aufgetreten.", + "hint": "Keine Sorge, Ihre Geheimnisse sind noch sicher. Versuchen Sie, die Seite zu aktualisieren.", + "error_details": "Fehlerdetails:", + "unknown_error": "Ein unbekannter Fehler ist aufgetreten", + "try_again_button": "Erneut versuchen", + "go_home_button": "Zur Startseite" + }, + "secret_requests_page": { + "title": "Geheimnis-Anfragen", + "description": "Fordern Sie Geheimnisse von anderen über sichere Links an", + "create_request_button": "Anfrage erstellen", + "no_requests": "Noch keine Geheimnis-Anfragen. Erstellen Sie eine, um zu beginnen.", + "table": { + "title_header": "Titel", + "status_header": "Status", + "secret_expiry_header": "Geheimnis-Ablauf", + "link_expires_header": "Link läuft ab", + "copy_link_tooltip": "Ersteller-Link kopieren", + "view_secret_tooltip": "Geheimnis anzeigen", + "cancel_tooltip": "Anfrage abbrechen" + }, + "status": { + "pending": "Ausstehend", + "fulfilled": "Erfüllt", + "expired": "Abgelaufen", + "cancelled": "Abgebrochen" + }, + "time": { + "days": "{{count}} Tag", + "days_plural": "{{count}} Tage", + "hours": "{{count}} Stunde", + "hours_plural": "{{count}} Stunden", + "minutes": "{{count}} Minute", + "minutes_plural": "{{count}} Minuten" + }, + "link_modal": { + "title": "Ersteller-Link", + "description": "Senden Sie diesen Link an die Person, die das Geheimnis bereitstellen soll. Sie kann das Geheimnis über diesen Link eingeben und verschlüsseln.", + "copy_button": "Link kopieren", + "close_button": "Schließen", + "warning": "Dieser Link kann nur einmal verwendet werden. Sobald ein Geheimnis übermittelt wurde, funktioniert der Link nicht mehr." + }, + "cancel_modal": { + "title": "Anfrage abbrechen", + "message": "Möchten Sie die Anfrage \"{{title}}\" wirklich abbrechen? Diese Aktion kann nicht rückgängig gemacht werden.", + "confirm_text": "Anfrage abbrechen", + "cancel_text": "Anfrage behalten" + }, + "toast": { + "copied": "Link in Zwischenablage kopiert", + "cancelled": "Anfrage abgebrochen", + "fetch_error": "Anfrage-Details konnten nicht abgerufen werden", + "cancel_error": "Anfrage konnte nicht abgebrochen werden" + } + }, + "create_request_page": { + "title": "Geheimnis-Anfrage erstellen", + "description": "Fordern Sie ein Geheimnis von jemandem an, indem Sie einen sicheren Link generieren, über den es übermittelt werden kann", + "back_button": "Zurück zu Geheimnis-Anfragen", + "form": { + "title_label": "Anfrage-Titel", + "title_placeholder": "z.B. AWS-Anmeldedaten für Projekt X", + "description_label": "Beschreibung (optional)", + "description_placeholder": "Geben Sie zusätzlichen Kontext zu Ihren Anforderungen...", + "link_validity_label": "Link-Gültigkeit", + "link_validity_hint": "Wie lange der Ersteller-Link aktiv bleibt", + "secret_settings_title": "Geheimnis-Einstellungen", + "secret_settings_description": "Diese Einstellungen gelten für das Geheimnis, sobald es erstellt wurde", + "secret_expiration_label": "Geheimnis-Ablauf", + "max_views_label": "Maximale Ansichten", + "password_label": "Passwortschutz (optional)", + "password_placeholder": "Passwort eingeben (mind. 5 Zeichen)", + "password_hint": "Empfänger benötigen dieses Passwort, um das Geheimnis anzuzeigen", + "ip_restriction_label": "IP-Einschränkung (optional)", + "ip_restriction_placeholder": "192.168.1.0/24 oder 203.0.113.5", + "prevent_burn_label": "Auto-Löschen verhindern (Geheimnis auch nach max. Ansichten behalten)", + "webhook_title": "Webhook-Benachrichtigung (optional)", + "webhook_description": "Benachrichtigung erhalten, wenn das Geheimnis übermittelt wird", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://ihr-server.com/webhook", + "webhook_url_hint": "HTTPS empfohlen. Eine Benachrichtigung wird gesendet, wenn das Geheimnis erstellt wird.", + "creating_button": "Wird erstellt...", + "create_button": "Anfrage erstellen" + }, + "validity": { + "30_days": "30 Tage", + "14_days": "14 Tage", + "7_days": "7 Tage", + "3_days": "3 Tage", + "1_day": "1 Tag", + "12_hours": "12 Stunden", + "1_hour": "1 Stunde" + }, + "success": { + "title": "Anfrage erstellt!", + "description": "Teilen Sie den Ersteller-Link mit der Person, die das Geheimnis bereitstellen soll", + "creator_link_label": "Ersteller-Link", + "webhook_secret_label": "Webhook-Geheimnis", + "webhook_secret_warning": "Speichern Sie dieses Geheimnis jetzt! Es wird nicht erneut angezeigt. Verwenden Sie es zur Überprüfung von Webhook-Signaturen.", + "expires_at": "Link läuft ab: {{date}}", + "create_another_button": "Weitere Anfrage erstellen", + "view_all_button": "Alle Anfragen anzeigen" + }, + "toast": { + "created": "Geheimnis-Anfrage erfolgreich erstellt", + "create_error": "Geheimnis-Anfrage konnte nicht erstellt werden", + "copied": "In Zwischenablage kopiert" + } + }, + "request_secret_page": { + "loading": "Anfrage wird geladen...", + "error": { + "title": "Anfrage nicht verfügbar", + "invalid_link": "Dieser Link ist ungültig oder wurde manipuliert.", + "not_found": "Diese Anfrage wurde nicht gefunden oder der Link ist ungültig.", + "already_fulfilled": "Diese Anfrage wurde bereits erfüllt oder ist abgelaufen.", + "generic": "Beim Laden der Anfrage ist ein Fehler aufgetreten.", + "go_home_button": "Zur Startseite" + }, + "form": { + "title": "Ein Geheimnis übermitteln", + "description": "Jemand hat Sie gebeten, ein Geheimnis sicher mit ihm zu teilen", + "password_protected_note": "Dieses Geheimnis wird passwortgeschützt sein", + "encryption_note": "Ihr Geheimnis wird in Ihrem Browser verschlüsselt, bevor es gesendet wird. Der Entschlüsselungsschlüssel wird nur in der endgültigen URL enthalten sein, die Sie teilen.", + "submitting_button": "Verschlüsseln & Übermitteln...", + "submit_button": "Geheimnis übermitteln" + }, + "success": { + "title": "Geheimnis erstellt!", + "description": "Ihr Geheimnis wurde verschlüsselt und sicher gespeichert", + "decryption_key_label": "Entschlüsselungsschlüssel", + "warning": "Wichtig: Kopieren Sie diesen Entschlüsselungsschlüssel jetzt und senden Sie ihn an den Anforderer. Dies ist das einzige Mal, dass Sie ihn sehen werden!", + "manual_send_note": "Sie müssen diesen Entschlüsselungsschlüssel manuell an die Person senden, die das Geheimnis angefordert hat. Sie haben die Geheimnis-URL bereits in ihrem Dashboard.", + "create_own_button": "Eigenes Geheimnis erstellen" + }, + "toast": { + "created": "Geheimnis erfolgreich übermittelt", + "create_error": "Geheimnis konnte nicht übermittelt werden", + "copied": "In Zwischenablage kopiert" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/en/en.json b/src/i18n/locales/en/en.json new file mode 100644 index 0000000..8211b9e --- /dev/null +++ b/src/i18n/locales/en/en.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Templates", + "description": "Quick-start with a template", + "templates": { + "credentials": "Login Credentials", + "api_key": "API Key", + "database": "Database", + "server": "Server Access", + "credit_card": "Payment Card", + "email": "Email Account" + } + }, + "editor": { + "tooltips": { + "copy_text": "Copy as Plain Text", + "copy_html": "Copy as HTML", + "copy_base64": "Copy as Base64", + "bold": "Bold", + "italic": "Italic", + "strikethrough": "Strikethrough", + "inline_code": "Inline Code", + "link": "Link", + "remove_link": "Remove Link", + "insert_password": "Insert Password", + "paragraph": "Paragraph", + "heading1": "Heading 1", + "heading2": "Heading 2", + "heading3": "Heading 3", + "bullet_list": "Bullet List", + "numbered_list": "Numbered List", + "blockquote": "Blockquote", + "code_block": "Code Block", + "undo": "Undo", + "redo": "Redo" + }, + "copy_success": { + "html": "HTML copied!", + "text": "Text copied!", + "base64": "Base64 copied!" + }, + "link_modal": { + "title": "Add Link", + "url_label": "URL", + "url_placeholder": "Enter URL", + "cancel": "Cancel", + "update": "Update", + "insert": "Insert" + }, + "password_modal": { + "title": "Generate Password", + "length_label": "Password Length", + "options_label": "Options", + "include_numbers": "Numbers", + "include_symbols": "Symbols", + "include_uppercase": "Uppercase", + "include_lowercase": "Lowercase", + "generated_password": "Generated Password", + "refresh": "Refresh", + "cancel": "Cancel", + "insert": "Insert", + "copied_and_added": "Password added and copied to clipboard", + "added": "Password added" + }, + "formatting_tools": "Formatting Tools", + "character_count": "characters" + }, + "create_button": { + "creating_secret": "Creating Secret...", + "create": "Create" + }, + "file_upload": { + "sign_in_to_upload": "Sign in to upload files", + "sign_in": "Sign In", + "drop_files_here": "Drop files here", + "drag_and_drop": "Drag and drop a file, or click to select a file", + "uploading": "Uploading...", + "upload_file": "Upload File", + "file_too_large": "File \"{{fileName}}\" ({{fileSize}} MB) exceeds the maximum size of {{maxSize}} MB", + "max_size_exceeded": "Total file size exceeds the maximum of {{maxSize}} MB" + }, + "footer": { + "tagline": "paste.es — Share secrets securely and ephemerally", + "privacy": "Privacy", + "terms": "Terms", + "api": "API", + "managed_hosting": "Managed Hosting", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Home", + "sign_in": "Sign In", + "sign_up": "Sign Up", + "dashboard": "Dashboard", + "hero_text_part1": "Share secrets securely with encrypted messages that automatically", + "hero_text_part2": " self-destruct", + "hero_text_part3": " after being read." + }, + "dashboard_layout": { + "secrets": "Secrets", + "secret_requests": "Secret Requests", + "account": "Account", + "analytics": "Analytics", + "users": "Users", + "invites": "Invites", + "instance": "Instance", + "sign_out": "Sign out", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Secret Created!", + "secret_created_description": "Your secret is now available at the following URL. Keep your decryption key safe, as it cannot be recovered.", + "secret_url_label": "Secret URL", + "decryption_key_label": "Decryption Key", + "password_label": "Password", + "create_new_secret_button": "Create New Secret", + "copy_url_button": "Copy URL", + "burn_secret_button": "Burn Secret", + "max_secrets_per_user_info": "You can create up to {{count}} secrets.", + "failed_to_burn": "Failed to burn secret. Please try again." + }, + "security_settings": { + "security_title": "Security", + "security_description": "Configure security settings for your secret", + "remember_settings": "Remember", + "private_title": "Private", + "private_description": "Private secrets are encrypted and can only be viewed with the decryption key, and/or password.", + "expiration_title": "Expiration", + "expiration_burn_after_time_description": "Set when the secret should be destroyed", + "expiration_default_description": "Set how long the secret should be available", + "max_views_title": "Max views", + "burn_after_time_mode_title": "Burn After Time Mode", + "burn_after_time_mode_description": "The secret will be destroyed after the time expires, regardless of how many times it's viewed.", + "password_protection_title": "Password Protection", + "password_protection_description": "Add an additional layer of security with a password", + "enter_password_label": "Enter Password", + "password_placeholder": "Enter a secure password...", + "password_hint": "Minimum 5 characters. Recipients will need this password to view the secret", + "password_error": "Password must be at least 5 characters", + "ip_restriction_title": "Restrict by IP or CIDR", + "ip_restriction_description": "The CIDR input will allow users to specify IP address ranges that can access the secret.", + "ip_address_cidr_label": "IP Address or CIDR Range", + "ip_address_cidr_placeholder": "192.168.1.0/24 or 203.0.113.5", + "ip_address_cidr_hint": "Only requests from these IP addresses will be able to access the secret", + "burn_after_time_title": "Burn after time expires", + "burn_after_time_description": "Burn the secret only after the time expires" + }, + "title_field": { + "placeholder": "Title", + "hint": "Give your secret a memorable title (optional)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "views" + }, + "account_page": { + "title": "Account Settings", + "description": "Manage your account preferences and security", + "tabs": { + "profile": "Profile", + "security": "Security", + "developer": "Developer", + "danger_zone": "Danger Zone" + }, + "profile_info": { + "title": "Profile Information", + "description": "Update your personal information", + "first_name_label": "First Name", + "last_name_label": "Last Name", + "username_label": "Username", + "email_label": "Email Address", + "saving_button": "Saving...", + "save_changes_button": "Save Changes" + }, + "profile_settings": { + "username_taken": "Username is already taken" + }, + "security_settings": { + "title": "Security Settings", + "description": "Manage your password and security preferences", + "change_password_title": "Change Password", + "current_password_label": "Current Password", + "current_password_placeholder": "Enter current password", + "new_password_label": "New Password", + "new_password_placeholder": "Enter new password", + "confirm_new_password_label": "Confirm New Password", + "confirm_new_password_placeholder": "Confirm new password", + "password_mismatch_alert": "New passwords do not match", + "changing_password_button": "Changing...", + "change_password_button": "Change Password", + "password_change_success": "Password changed successfully!", + "password_change_error": "Failed to change password. Please try again." + }, + "two_factor": { + "title": "Two-Factor Authentication", + "description": "Add an extra layer of security to your account", + "enabled": "Enabled", + "disabled": "Not enabled", + "setup_button": "Set Up 2FA", + "disable_button": "Disable 2FA", + "enter_password_to_enable": "Enter your password to enable two-factor authentication.", + "continue": "Continue", + "scan_qr_code": "Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.).", + "manual_entry_hint": "Or manually enter this code in your authenticator app:", + "enter_verification_code": "Enter the 6-digit code from your authenticator app to verify setup.", + "verification_code": "Verification Code", + "verify_and_enable": "Verify & Enable", + "back": "Back", + "disable_title": "Disable Two-Factor Authentication", + "disable_warning": "Disabling 2FA will make your account less secure. You'll need to enter your password to confirm.", + "invalid_password": "Invalid password. Please try again.", + "invalid_code": "Invalid verification code. Please try again.", + "enable_error": "Failed to enable 2FA. Please try again.", + "verify_error": "Failed to verify 2FA code. Please try again.", + "disable_error": "Failed to disable 2FA. Please try again.", + "backup_codes_title": "Backup Codes", + "backup_codes_description": "Save these backup codes in a safe place. You can use them to access your account if you lose access to your authenticator app.", + "backup_codes_warning": "Each code can only be used once. Store them securely!", + "backup_codes_saved": "I've saved my backup codes" + }, + "danger_zone": { + "title": "Danger Zone", + "description": "Irreversible and destructive actions", + "delete_account_title": "Delete Account", + "delete_account_description": "Once you delete your account, there is no going back. This will permanently delete your account, all your secrets, and remove all associated data. This action cannot be undone.", + "delete_account_bullet1": "All your secrets will be permanently deleted", + "delete_account_bullet2": "Your account data will be removed from our servers", + "delete_account_bullet3": "Any shared secret links will become invalid", + "delete_account_bullet4": "This action cannot be reversed", + "delete_account_confirm": "Are you sure you want to delete your account? This action cannot be undone.", + "delete_account_button": "Delete Account", + "deleting_account_button": "Deleting Account..." + }, + "developer": { + "title": "API Keys", + "description": "Manage API keys for programmatic access", + "create_key": "Create Key", + "create_key_title": "Create API Key", + "key_name": "Key Name", + "key_name_placeholder": "e.g., My Integration", + "expiration": "Expiration", + "never_expires": "Never expires", + "expires_30_days": "30 days", + "expires_90_days": "90 days", + "expires_1_year": "1 year", + "create_button": "Create", + "name_required": "Key name is required", + "create_error": "Failed to create API key", + "key_created": "API Key Created!", + "key_warning": "Copy this key now. You won't be able to see it again.", + "dismiss": "I've copied the key", + "no_keys": "No API keys yet. Create one to get started.", + "created": "Created", + "last_used": "Last used", + "expires": "Expires", + "docs_hint": "Learn how to use API keys in the", + "api_docs": "API Documentation" + } + }, + "analytics_page": { + "title": "Analytics", + "description": "Track your secret sharing activity and insights", + "time_range": { + "last_7_days": "Last 7 days", + "last_14_days": "Last 14 days", + "last_30_days": "Last 30 days" + }, + "total_secrets": "Total Secrets", + "from_last_period": "+{{percentage}}% from last period", + "total_views": "Total Views", + "avg_views_per_secret": "Avg Views/Secret", + "active_secrets": "Active Secrets", + "daily_activity": { + "title": "Daily Activity", + "description": "Secrets created and views over time", + "secrets": "Secrets", + "views": "Views", + "secrets_created": "Secrets Created", + "secret_views": "Secret Views", + "date": "Date", + "trend": "Change", + "vs_previous": "vs previous day", + "no_data": "No activity data available yet." + }, + "locale": "en-US", + "top_countries": { + "title": "Top Countries", + "description": "Where your secrets are being viewed", + "views": "views" + }, + "secret_types": { + "title": "Secret Types", + "description": "Distribution by protection level", + "password_protected": "Password Protected", + "ip_restricted": "IP Restricted", + "burn_after_time": "Burn After Time" + }, + "expiration_stats": { + "title": "Expiration Stats", + "description": "How long secrets typically last", + "one_hour": "1 Hour", + "one_day": "1 Day", + "one_week_plus": "1 Week+" + }, + "visitor_analytics": { + "title": "Visitor Analytics", + "description": "Page views and unique visitors", + "unique": "Unique", + "views": "Views", + "date": "Date", + "trend": "Change", + "vs_previous": "vs previous day", + "no_data": "No visitor data available yet." + }, + "secret_requests": { + "total": "Secret Requests", + "fulfilled": "Fulfilled Requests" + }, + "loading": "Loading analytics...", + "no_permission": "You don't have permission to view analytics.", + "failed_to_fetch": "Failed to fetch analytics data." + }, + "instance_page": { + "title": "Instance Settings", + "description": "Configure your Hemmelig instance", + "managed_mode": { + "title": "Managed Mode", + "description": "This instance is managed via environment variables. Settings are read-only." + }, + "tabs": { + "general": "General", + "security": "Security", + "organization": "Organization", + "webhook": "Webhooks", + "metrics": "Metrics" + }, + "system_status": { + "title": "System Status", + "description": "Instance health and performance metrics", + "version": "Version", + "uptime": "Uptime", + "memory": "Memory", + "cpu_usage": "CPU Usage" + }, + "general_settings": { + "title": "General Settings", + "description": "Basic instance configuration", + "instance_name_label": "Instance Name", + "logo_label": "Instance Logo", + "logo_upload": "Upload Logo", + "logo_remove": "Remove logo", + "logo_hint": "PNG, JPEG, GIF, SVG, or WebP. Max 512KB.", + "logo_alt": "Instance logo", + "logo_invalid_type": "Invalid file type. Please upload a PNG, JPEG, GIF, SVG, or WebP image.", + "logo_too_large": "File is too large. Maximum size is 512KB.", + "default_expiration_label": "Default Secret Expiration", + "max_secrets_per_user_label": "Max Secrets per User", + "max_secret_size_label": "Max Secret Size (MB)", + "instance_description_label": "Instance Description", + "important_message_label": "Important Message", + "important_message_placeholder": "Enter an important message to display to all users...", + "important_message_hint": "This message will be displayed as an alert banner on the homepage. Supports markdown formatting. Leave empty to hide.", + "allow_registration_title": "Allow Registration", + "allow_registration_description": "Allow new users to register", + "email_verification_title": "Email Verification", + "email_verification_description": "Require email verification" + }, + "saving_button": "Saving...", + "save_settings_button": "Save Settings", + "security_settings": { + "title": "Security Settings", + "description": "Configure security and access controls", + "rate_limiting_title": "Rate Limiting", + "rate_limiting_description": "Enable request rate limiting", + "max_password_attempts_label": "Max Password Attempts", + "session_timeout_label": "Session Timeout (hours)", + "allow_file_uploads_title": "Allow File Uploads", + "allow_file_uploads_description": "Allow users to attach files to secrets" + }, + "email_settings": { + "title": "Email Settings", + "description": "Configure SMTP and email notifications", + "smtp_host_label": "SMTP Host", + "smtp_port_label": "SMTP Port", + "username_label": "Username", + "password_label": "Password" + }, + "database_info": { + "title": "Database Information", + "description": "Database status and statistics", + "stats_title": "Database Stats", + "total_secrets": "Total Secrets:", + "total_users": "Total Users:", + "disk_usage": "Disk Usage:", + "connection_status_title": "Connection Status", + "connected": "Connected", + "connected_description": "Database is healthy and responding normally" + }, + "system_info": { + "title": "System Information", + "description": "Server details and maintenance", + "system_info_title": "System Info", + "version": "Version:", + "uptime": "Uptime:", + "status": "Status:", + "resource_usage_title": "Resource Usage", + "memory": "Memory:", + "cpu": "CPU:", + "disk": "Disk:" + }, + "maintenance_actions": { + "title": "Maintenance Actions", + "description": "These actions can affect system availability. Use with caution.", + "restart_service_button": "Restart Service", + "clear_cache_button": "Clear Cache", + "export_logs_button": "Export Logs" + } + }, + "secrets_page": { + "title": "Your Secrets", + "description": "Manage and monitor your shared secrets", + "create_secret_button": "Create Secret", + "search_placeholder": "Search secrets...", + "filter": { + "all_secrets": "All Secrets", + "active": "Active", + "expired": "Expired" + }, + "total_secrets": "Total Secrets", + "active_secrets": "Active", + "expired_secrets": "Expired", + "no_secrets_found_title": "No secrets found", + "no_secrets_found_description_filter": "Try adjusting your search or filter criteria.", + "no_secrets_found_description_empty": "Create your first secret to get started.", + "password_protected": "Password", + "files": "files", + "table": { + "secret_header": "Secret", + "created_header": "Created", + "status_header": "Status", + "views_header": "Views", + "actions_header": "Actions", + "untitled_secret": "Untitled Secret", + "expired_status": "Expired", + "active_status": "Active", + "never_expires": "Never expires", + "expired_time": "Expired", + "views_left": "views left", + "copy_url_tooltip": "Copy URL", + "open_secret_tooltip": "Open Secret", + "delete_secret_tooltip": "Delete Secret", + "delete_confirmation_title": "Are you sure?", + "delete_confirmation_text": "This action cannot be undone. This will permanently delete the secret.", + "delete_confirm_button": "Yes, delete it", + "delete_cancel_button": "Cancel" + } + }, + "users_page": { + "title": "User Management", + "description": "Manage users and their permissions", + "add_user_button": "Add User", + "search_placeholder": "Search users...", + "filter": { + "all_roles": "All Roles", + "admin": "Admin", + "user": "User", + "all_status": "All Status", + "active": "Active", + "suspended": "Suspended", + "pending": "Pending" + }, + "total_users": "Total Users", + "active_users": "Active", + "admins": "Admins", + "pending_users": "Pending", + "no_users_found_title": "No users found", + "no_users_found_description_filter": "Try adjusting your search or filter criteria.", + "no_users_found_description_empty": "No users have been added yet.", + "table": { + "user_header": "User", + "role_header": "Role", + "status_header": "Status", + "activity_header": "Activity", + "last_login_header": "Last Login", + "actions_header": "Actions", + "created_at": "Created At" + }, + "status": { + "active": "Active", + "banned": "Banned" + }, + "delete_user_modal": { + "title": "Delete User", + "confirmation_message": "Are you sure you want to delete the user {{username}}? This action cannot be undone.", + "confirm_button": "Delete", + "cancel_button": "Cancel" + }, + "edit_user_modal": { + "title": "Edit User: {{username}}", + "username_label": "Username", + "email_label": "Email", + "role_label": "Role", + "banned_label": "Banned", + "save_button": "Save", + "cancel_button": "Cancel" + }, + "add_user_modal": { + "title": "Add New User", + "name_label": "Name", + "username_label": "Username", + "email_label": "Email", + "password_label": "Password", + "role_label": "Role", + "save_button": "Add User", + "cancel_button": "Cancel" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Back to Sign In", + "check_email_title": "Check your email", + "check_email_description": "We've sent a password reset link to {{email}}", + "did_not_receive_email": "Didn't receive the email? Check your spam folder or try again.", + "try_again_button": "Try again", + "forgot_password_title": "Forgot password?", + "forgot_password_description": "No worries, we'll send you reset instructions", + "email_label": "Email", + "email_placeholder": "Enter your email", + "email_hint": "Enter the email associated with your account", + "sending_button": "Sending...", + "reset_password_button": "Reset Password", + "remember_password": "Remember your password?", + "sign_in_link": "Sign in", + "unexpected_error": "An unexpected error occurred. Please try again." + }, + "login_page": { + "back_to_hemmelig": "Back to Hemmelig", + "welcome_back": "Welcome back to Hemmelig", + "welcome_back_title": "Welcome back", + "welcome_back_description": "Sign in to your Hemmelig account", + "username_label": "Username", + "username_placeholder": "Enter your username", + "password_label": "Password", + "password_placeholder": "Enter your password", + "forgot_password_link": "Forgot your password?", + "signing_in_button": "Signing in...", + "sign_in_button": "Sign In", + "or_continue_with": "Or continue with", + "continue_with_github": "Continue with GitHub", + "no_account_question": "Don't have an account?", + "sign_up_link": "Sign up", + "unexpected_error": "An unexpected error occurred. Please try again." + }, + "register_page": { + "back_to_hemmelig": "Back to Hemmelig", + "join_hemmelig": "Join Hemmelig to share secrets securely", + "email_password_disabled_message": "Email and password registration is disabled. Please use one of the social login options below.", + "create_account_title": "Create account", + "create_account_description": "Join Hemmelig to share secrets securely", + "username_label": "Username", + "username_placeholder": "Choose a username", + "email_label": "Email", + "email_placeholder": "Enter your email", + "password_label": "Password", + "password_placeholder": "Create a password", + "password_strength_label": "Password strength", + "password_strength_levels": { + "very_weak": "Very Weak", + "weak": "Weak", + "fair": "Fair", + "good": "Good", + "strong": "Strong" + }, + "confirm_password_label": "Confirm Password", + "confirm_password_placeholder": "Confirm your password", + "passwords_match": "Passwords match", + "passwords_do_not_match": "Passwords do not match", + "password_mismatch_alert": "Passwords do not match", + "creating_account_button": "Creating account...", + "create_account_button": "Create Account", + "or_continue_with": "Or continue with", + "continue_with_github": "Continue with GitHub", + "already_have_account_question": "Already have an account?", + "sign_in_link": "Sign in", + "invite_code_label": "Invite Code", + "invite_code_placeholder": "Enter your invite code", + "invite_code_required": "Invite code is required", + "invalid_invite_code": "Invalid invite code", + "failed_to_validate_invite": "Failed to validate invite code", + "unexpected_error": "An unexpected error occurred. Please try again.", + "email_domain_not_allowed": "Email domain not allowed", + "account_already_exists": "An account with this email already exists. Please sign in instead." + }, + "secret_form": { + "failed_to_create_secret": "Failed to create secret: {{errorMessage}}", + "failed_to_upload_file": "Failed to upload file: {{fileName}}" + }, + "secret_page": { + "password_label": "Password", + "password_placeholder": "Enter password to view secret", + "decryption_key_label": "Decryption Key", + "decryption_key_placeholder": "Enter the decryption key", + "view_secret_button": "View Secret", + "views_remaining_tooltip": "Views remaining: {{count}}", + "loading_message": "Decrypting secret...", + "files_title": "Attached Files", + "secret_waiting_title": "Someone shared a secret with you", + "secret_waiting_description": "This secret is encrypted and can only be viewed once you click the button below.", + "one_view_remaining": "This secret can only be viewed 1 more time", + "views_remaining": "This secret can be viewed {{count}} more times", + "view_warning": "Once viewed, this action cannot be undone", + "secret_revealed": "Secret", + "copy_secret": "Copy to clipboard", + "download": "Download", + "create_your_own": "Create your own secret", + "encrypted_secret": "Encrypted Secret", + "unlock_secret": "Unlock Secret", + "delete_secret": "Delete Secret", + "delete_modal_title": "Delete Secret", + "delete_modal_message": "Are you sure you want to delete this secret? This action cannot be undone.", + "decryption_failed": "Failed to decrypt the secret. Please check your password or decryption key.", + "fetch_error": "An error occurred while fetching the secret. Please try again." + }, + "expiration": { + "28_days": "28 Days", + "14_days": "14 Days", + "7_days": "7 Days", + "3_days": "3 Days", + "1_day": "1 Day", + "12_hours": "12 Hours", + "4_hours": "4 Hours", + "1_hour": "1 Hour", + "30_minutes": "30 Minutes", + "5_minutes": "5 Minutes" + }, + "error_display": { + "clear_errors_button_title": "Clear errors" + }, + "secret_not_found_page": { + "title": "Secret Not Found", + "message": "The secret you are looking for does not exist, has expired, or has been burned.", + "error_details": "Error details:", + "go_home_button": "Go to Homepage" + }, + "organization_page": { + "title": "Organization Settings", + "description": "Configure organization-wide settings and access controls", + "registration_settings": { + "title": "Registration Settings", + "description": "Control how users can join your organization", + "invite_only_title": "Invite Only Registration", + "invite_only_description": "Users can only register with a valid invite code", + "require_registered_user_title": "Registered Users Only", + "require_registered_user_description": "Only registered users can create secrets", + "disable_email_password_signup_title": "Disable Email/Password Signup", + "disable_email_password_signup_description": "Disable registration with email and password (social login only)", + "allowed_domains_title": "Allowed Email Domains", + "allowed_domains_description": "Only allow registration from specific email domains (comma-separated, e.g., company.com, org.net)", + "allowed_domains_placeholder": "company.com, org.net", + "allowed_domains_hint": "Comma-separated list of email domains. Leave empty to allow all domains." + }, + "invite_codes": { + "title": "Invite Codes", + "description": "Create and manage invitation codes for new users", + "create_invite_button": "Create Invite Code", + "code_header": "Code", + "uses_header": "Uses", + "expires_header": "Expires", + "actions_header": "Actions", + "unlimited": "Unlimited", + "never": "Never", + "expired": "Expired", + "no_invites": "No invite codes yet", + "no_invites_description": "Create an invite code to allow new users to register", + "copy_tooltip": "Copy code", + "delete_tooltip": "Delete code" + }, + "create_invite_modal": { + "title": "Create Invite Code", + "max_uses_label": "Max Uses", + "max_uses_placeholder": "Leave empty for unlimited", + "expiration_label": "Expiration", + "expiration_options": { + "never": "Never", + "24_hours": "24 Hours", + "7_days": "7 Days", + "30_days": "30 Days" + }, + "cancel_button": "Cancel", + "create_button": "Create" + }, + "saving_button": "Saving...", + "save_settings_button": "Save Settings" + }, + "invites_page": { + "title": "Invite Codes", + "description": "Manage invite codes for new user registrations", + "create_invite_button": "Create Invite", + "loading": "Loading invite codes...", + "table": { + "code_header": "Code", + "uses_header": "Uses", + "expires_header": "Expires", + "status_header": "Status", + "never": "Never" + }, + "status": { + "active": "Active", + "expired": "Expired", + "used": "Used", + "inactive": "Inactive" + }, + "no_invites": "No invite codes yet", + "create_modal": { + "title": "Create Invite Code", + "max_uses_label": "Maximum Uses", + "expires_in_label": "Expires In (days)" + }, + "delete_modal": { + "title": "Deactivate Invite Code", + "confirm_text": "Deactivate", + "cancel_text": "Cancel", + "message": "Are you sure you want to deactivate invite code {{code}}? This action cannot be undone." + }, + "toast": { + "created": "Invite code created", + "deactivated": "Invite code deactivated", + "copied": "Invite code copied to clipboard", + "fetch_error": "Failed to fetch invite codes", + "create_error": "Failed to create invite code", + "delete_error": "Failed to deactivate invite code" + } + }, + "social_login": { + "continue_with": "Continue with {{provider}}", + "sign_up_with": "Sign up with {{provider}}" + }, + "setup_page": { + "title": "Welcome to Hemmelig", + "description": "Create your admin account to get started", + "name_label": "Full Name", + "name_placeholder": "Enter your full name", + "username_label": "Username", + "username_placeholder": "Choose a username", + "email_label": "Email Address", + "email_placeholder": "Enter your email", + "password_label": "Password", + "password_placeholder": "Create a password (min 8 characters)", + "confirm_password_label": "Confirm Password", + "confirm_password_placeholder": "Confirm your password", + "create_admin": "Create Admin Account", + "creating": "Creating account...", + "success": "Admin account created successfully! Please log in.", + "error": "Failed to create admin account", + "passwords_mismatch": "Passwords do not match", + "password_too_short": "Password must be at least 8 characters", + "note": "This setup can only be completed once. The admin account will have full access to manage this instance." + }, + "theme_toggle": { + "switch_to_light": "Switch to light mode", + "switch_to_dark": "Switch to dark mode" + }, + "webhook_settings": { + "title": "Webhook Notifications", + "description": "Notify external services when secrets are viewed or burned", + "enable_webhooks_title": "Enable Webhooks", + "enable_webhooks_description": "Send HTTP POST requests to your webhook URL when events occur", + "webhook_url_label": "Webhook URL", + "webhook_url_placeholder": "https://example.com/webhook", + "webhook_url_hint": "The URL where webhook payloads will be sent", + "webhook_secret_label": "Webhook Secret", + "webhook_secret_placeholder": "Enter a secret for HMAC signing", + "webhook_secret_hint": "Used to sign webhook payloads with HMAC-SHA256. The signature is sent in the X-Hemmelig-Signature header.", + "events_title": "Webhook Events", + "on_view_title": "Secret Viewed", + "on_view_description": "Send a webhook when a secret is viewed", + "on_burn_title": "Secret Burned", + "on_burn_description": "Send a webhook when a secret is burned or deleted" + }, + "metrics_settings": { + "title": "Prometheus Metrics", + "description": "Expose metrics for monitoring with Prometheus", + "enable_metrics_title": "Enable Prometheus Metrics", + "enable_metrics_description": "Expose a /api/metrics endpoint for Prometheus scraping", + "metrics_secret_label": "Metrics Secret", + "metrics_secret_placeholder": "Enter a secret for authentication", + "metrics_secret_hint": "Used as a Bearer token to authenticate requests to the metrics endpoint. Leave empty for no authentication (not recommended).", + "endpoint_info_title": "Endpoint Information", + "endpoint_info_description": "Once enabled, metrics will be available at:", + "endpoint_auth_hint": "Include the secret as a Bearer token in the Authorization header when fetching metrics." + }, + "verify_2fa_page": { + "back_to_login": "Back to Login", + "title": "Two-Factor Authentication", + "description": "Enter the 6-digit code from your authenticator app", + "enter_code_hint": "Enter the code from your authenticator app", + "verifying": "Verifying...", + "verify_button": "Verify", + "invalid_code": "Invalid verification code. Please try again.", + "unexpected_error": "An unexpected error occurred. Please try again." + }, + "common": { + "error": "Error", + "cancel": "Cancel", + "confirm": "Confirm", + "ok": "OK", + "delete": "Delete", + "deleting": "Deleting...", + "loading": "Loading..." + }, + "pagination": { + "showing": "Showing {{start}} to {{end}} of {{total}} results", + "previous_page": "Previous page", + "next_page": "Next page" + }, + "not_found_page": { + "title": "Page Not Found", + "message": "This page has vanished into thin air, just like our secrets do.", + "hint": "The page you're looking for doesn't exist or has been moved.", + "go_home_button": "Go Home", + "create_secret_button": "Create Secret" + }, + "error_boundary": { + "title": "Something Went Wrong", + "message": "An unexpected error occurred while processing your request.", + "hint": "Don't worry, your secrets are still safe. Try refreshing the page.", + "error_details": "Error details:", + "unknown_error": "An unknown error occurred", + "try_again_button": "Try Again", + "go_home_button": "Go Home" + }, + "secret_requests_page": { + "title": "Secret Requests", + "description": "Request secrets from others via secure links", + "create_request_button": "Create Request", + "no_requests": "No secret requests yet. Create one to get started.", + "table": { + "title_header": "Title", + "status_header": "Status", + "secret_expiry_header": "Secret Expiry", + "link_expires_header": "Link Expires", + "copy_link_tooltip": "Copy Creator Link", + "view_secret_tooltip": "View Secret", + "cancel_tooltip": "Cancel Request" + }, + "status": { + "pending": "Pending", + "fulfilled": "Fulfilled", + "expired": "Expired", + "cancelled": "Cancelled" + }, + "time": { + "days": "{{count}} day", + "days_plural": "{{count}} days", + "hours": "{{count}} hour", + "hours_plural": "{{count}} hours", + "minutes": "{{count}} minute", + "minutes_plural": "{{count}} minutes" + }, + "link_modal": { + "title": "Creator Link", + "description": "Send this link to the person who should provide the secret. They will be able to enter and encrypt the secret using this link.", + "copy_button": "Copy Link", + "close_button": "Close", + "warning": "This link can only be used once. Once a secret is submitted, the link will no longer work." + }, + "cancel_modal": { + "title": "Cancel Request", + "message": "Are you sure you want to cancel the request \"{{title}}\"? This action cannot be undone.", + "confirm_text": "Cancel Request", + "cancel_text": "Keep Request" + }, + "toast": { + "copied": "Link copied to clipboard", + "cancelled": "Request cancelled", + "fetch_error": "Failed to fetch request details", + "cancel_error": "Failed to cancel request" + } + }, + "create_request_page": { + "title": "Create Secret Request", + "description": "Request a secret from someone by generating a secure link they can use to submit it", + "back_button": "Back to Secret Requests", + "form": { + "title_label": "Request Title", + "title_placeholder": "e.g., AWS Credentials for Project X", + "description_label": "Description (optional)", + "description_placeholder": "Provide additional context about what you need...", + "link_validity_label": "Link Validity", + "link_validity_hint": "How long the creator link will remain active", + "secret_settings_title": "Secret Settings", + "secret_settings_description": "These settings will apply to the secret once it's created", + "secret_expiration_label": "Secret Expiration", + "max_views_label": "Maximum Views", + "password_label": "Password Protection (optional)", + "password_placeholder": "Enter a password (min 5 characters)", + "password_hint": "Recipients will need this password to view the secret", + "ip_restriction_label": "IP Restriction (optional)", + "ip_restriction_placeholder": "192.168.1.0/24 or 203.0.113.5", + "prevent_burn_label": "Prevent auto-burn (keep secret even after max views)", + "webhook_title": "Webhook Notification (optional)", + "webhook_description": "Get notified when the secret is submitted", + "webhook_url_label": "Webhook URL", + "webhook_url_placeholder": "https://your-server.com/webhook", + "webhook_url_hint": "HTTPS recommended. A notification will be sent when the secret is created.", + "creating_button": "Creating...", + "create_button": "Create Request" + }, + "validity": { + "30_days": "30 Days", + "14_days": "14 Days", + "7_days": "7 Days", + "3_days": "3 Days", + "1_day": "1 Day", + "12_hours": "12 Hours", + "1_hour": "1 Hour" + }, + "success": { + "title": "Request Created!", + "description": "Share the creator link with the person who should provide the secret", + "creator_link_label": "Creator Link", + "webhook_secret_label": "Webhook Secret", + "webhook_secret_warning": "Save this secret now! It won't be shown again. Use it to verify webhook signatures.", + "expires_at": "Link expires: {{date}}", + "create_another_button": "Create Another Request", + "view_all_button": "View All Requests" + }, + "toast": { + "created": "Secret request created successfully", + "create_error": "Failed to create secret request", + "copied": "Copied to clipboard" + } + }, + "request_secret_page": { + "loading": "Loading request...", + "error": { + "title": "Request Unavailable", + "invalid_link": "This link is invalid or has been tampered with.", + "not_found": "This request was not found or the link is invalid.", + "already_fulfilled": "This request has already been fulfilled or has expired.", + "generic": "An error occurred while loading the request.", + "go_home_button": "Go to Homepage" + }, + "form": { + "title": "Submit a Secret", + "description": "Someone has requested that you share a secret with them securely", + "password_protected_note": "This secret will be password protected", + "encryption_note": "Your secret will be encrypted in your browser before being sent. The decryption key will only be included in the final URL you share.", + "submitting_button": "Encrypting & Submitting...", + "submit_button": "Submit Secret" + }, + "success": { + "title": "Secret Created!", + "description": "Your secret has been encrypted and stored securely", + "decryption_key_label": "Decryption Key", + "warning": "Important: Copy this decryption key now and send it to the requester. This is the only time you will see it!", + "manual_send_note": "You must manually send this decryption key to the person who requested the secret. They already have the secret URL in their dashboard.", + "create_own_button": "Create Your Own Secret" + }, + "toast": { + "created": "Secret submitted successfully", + "create_error": "Failed to submit secret", + "copied": "Copied to clipboard" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/es/es.json b/src/i18n/locales/es/es.json new file mode 100644 index 0000000..090d2f5 --- /dev/null +++ b/src/i18n/locales/es/es.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Plantillas", + "description": "Comienza rápidamente con una plantilla", + "templates": { + "credentials": "Credenciales de inicio de sesión", + "api_key": "Clave API", + "database": "Base de datos", + "server": "Acceso al servidor", + "credit_card": "Tarjeta de pago", + "email": "Cuenta de correo electrónico" + } + }, + "editor": { + "tooltips": { + "copy_text": "Copiar como texto plano", + "copy_html": "Copiar como HTML", + "copy_base64": "Copiar como Base64", + "bold": "Negrita", + "italic": "Cursiva", + "strikethrough": "Tachado", + "inline_code": "Código en línea", + "link": "Enlace", + "remove_link": "Eliminar enlace", + "insert_password": "Insertar contraseña", + "paragraph": "Párrafo", + "heading1": "Encabezado 1", + "heading2": "Encabezado 2", + "heading3": "Encabezado 3", + "bullet_list": "Lista de viñetas", + "numbered_list": "Lista numerada", + "blockquote": "Cita en bloque", + "code_block": "Bloque de código", + "undo": "Deshacer", + "redo": "Rehacer" + }, + "copy_success": { + "html": "HTML copiado!", + "text": "Texto copiado!", + "base64": "Base64 copiado!" + }, + "link_modal": { + "title": "Añadir enlace", + "url_label": "URL", + "url_placeholder": "Introducir URL", + "cancel": "Cancelar", + "update": "Actualizar", + "insert": "Insertar" + }, + "password_modal": { + "title": "Generar contraseña", + "length_label": "Longitud de la contraseña", + "options_label": "Opciones", + "include_numbers": "Números", + "include_symbols": "Símbolos", + "include_uppercase": "Mayúsculas", + "include_lowercase": "Minúsculas", + "generated_password": "Contraseña generada", + "refresh": "Actualizar", + "cancel": "Cancelar", + "insert": "Insertar", + "copied_and_added": "Contraseña añadida y copiada al portapapeles", + "added": "Contraseña añadida" + }, + "formatting_tools": "Herramientas de formato", + "character_count": "caracteres" + }, + "create_button": { + "creating_secret": "Creando secreto...", + "create": "Crear" + }, + "file_upload": { + "sign_in_to_upload": "Inicia sesión para subir archivos", + "sign_in": "Iniciar sesión", + "drop_files_here": "Suelta los archivos aquí", + "drag_and_drop": "Arrastra y suelta un archivo, o haz clic para seleccionar un archivo", + "uploading": "Subiendo...", + "upload_file": "Subir archivo", + "file_too_large": "El archivo \"{{fileName}}\" ({{fileSize}} MB) excede el tamaño máximo de {{maxSize}} MB", + "max_size_exceeded": "El tamaño total del archivo excede el máximo de {{maxSize}} MB" + }, + "footer": { + "tagline": "paste.es — Comparte secretos de forma segura y efímera", + "sponsored_by": "Alojado por cloudhost.es", + "privacy": "Privacidad", + "terms": "Términos", + "api": "API", + "managed_hosting": "Alojamiento gestionado" + }, + "header": { + "home": "Inicio", + "sign_in": "Iniciar sesión", + "sign_up": "Registrarse", + "dashboard": "Panel", + "hero_text_part1": "Comparte secretos de forma segura con mensajes cifrados que automáticamente", + "hero_text_part2": " se autodestruyen", + "hero_text_part3": " después de ser leídos." + }, + "dashboard_layout": { + "secrets": "Secretos", + "secret_requests": "Solicitudes de secretos", + "account": "Cuenta", + "analytics": "Analítica", + "users": "Usuarios", + "invites": "Invitaciones", + "instance": "Instancia", + "sign_out": "Cerrar sesión", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "¡Secreto creado!", + "secret_created_description": "Tu secreto ya está disponible en la siguiente URL. Guarda tu clave de descifrado de forma segura, ya que no se puede recuperar.", + "secret_url_label": "URL del secreto", + "decryption_key_label": "Clave de descifrado", + "password_label": "Contraseña", + "create_new_secret_button": "Crear nuevo secreto", + "copy_url_button": "Copiar URL", + "burn_secret_button": "Quemar secreto", + "max_secrets_per_user_info": "Puedes crear hasta {{count}} secretos.", + "failed_to_burn": "Error al quemar el secreto. Por favor, inténtalo de nuevo." + }, + "security_settings": { + "security_title": "Seguridad", + "security_description": "Configura los ajustes de seguridad para tu secreto", + "remember_settings": "Recordar", + "private_title": "Privado", + "private_description": "Los secretos privados están cifrados y solo se pueden ver con la clave de descifrado y/o la contraseña.", + "expiration_title": "Caducidad", + "expiration_burn_after_time_description": "Establece cuándo debe destruirse el secreto", + "expiration_default_description": "Establece cuánto tiempo debe estar disponible el secreto", + "max_views_title": "Vistas máximas", + "burn_after_time_mode_title": "Modo de autodestrucción por tiempo", + "burn_after_time_mode_description": "El secreto se destruirá después de que expire el tiempo, independientemente de cuántas veces se vea.", + "password_protection_title": "Protección con contraseña", + "password_protection_description": "Añade una capa adicional de seguridad con una contraseña", + "enter_password_label": "Introduce la contraseña", + "password_placeholder": "Introduce una contraseña segura...", + "password_hint": "Mínimo 5 caracteres. Los destinatarios necesitarán esta contraseña para ver el secreto", + "password_error": "La contraseña debe tener al menos 5 caracteres", + "ip_restriction_title": "Restringir por IP o CIDR", + "ip_restriction_description": "La entrada CIDR permitirá a los usuarios especificar rangos de direcciones IP que pueden acceder al secreto.", + "ip_address_cidr_label": "Dirección IP o rango CIDR", + "ip_address_cidr_placeholder": "192.168.1.0/24 o 203.0.113.5", + "ip_address_cidr_hint": "Solo las solicitudes de estas direcciones IP podrán acceder al secreto", + "burn_after_time_title": "Autodestrucción al expirar el tiempo", + "burn_after_time_description": "Quemar el secreto solo después de que expire el tiempo" + }, + "title_field": { + "placeholder": "Título", + "hint": "Dale a tu secreto un título memorable (opcional)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "vista{{count}}" + }, + "account_page": { + "title": "Configuración de la cuenta", + "description": "Gestiona las preferencias y la seguridad de tu cuenta", + "tabs": { + "profile": "Perfil", + "security": "Seguridad", + "developer": "Desarrollador", + "danger_zone": "Zona de peligro" + }, + "profile_info": { + "title": "Información del perfil", + "description": "Actualiza tu información personal", + "first_name_label": "Nombre", + "last_name_label": "Apellido", + "username_label": "Nombre de usuario", + "email_label": "Dirección de correo electrónico", + "saving_button": "Guardando...", + "save_changes_button": "Guardar cambios" + }, + "profile_settings": { + "username_taken": "El nombre de usuario ya está en uso" + }, + "security_settings": { + "title": "Configuración de seguridad", + "description": "Gestiona tu contraseña y preferencias de seguridad", + "change_password_title": "Cambiar contraseña", + "current_password_label": "Contraseña actual", + "current_password_placeholder": "Introduce la contraseña actual", + "new_password_label": "Nueva contraseña", + "new_password_placeholder": "Introduce la nueva contraseña", + "confirm_new_password_label": "Confirma la nueva contraseña", + "confirm_new_password_placeholder": "Confirma la nueva contraseña", + "password_mismatch_alert": "Las nuevas contraseñas no coinciden", + "changing_password_button": "Cambiando...", + "change_password_button": "Cambiar contraseña", + "password_change_success": "¡Contraseña cambiada con éxito!", + "password_change_error": "Error al cambiar la contraseña. Por favor, inténtalo de nuevo." + }, + "two_factor": { + "title": "Autenticación de dos factores", + "description": "Añade una capa extra de seguridad a tu cuenta", + "enabled": "Activada", + "disabled": "No activada", + "setup_button": "Configurar 2FA", + "disable_button": "Desactivar 2FA", + "enter_password_to_enable": "Introduce tu contraseña para activar la autenticación de dos factores.", + "continue": "Continuar", + "scan_qr_code": "Escanea este código QR con tu aplicación de autenticación (Google Authenticator, Authy, etc.).", + "manual_entry_hint": "O introduce manualmente este código en tu aplicación de autenticación:", + "enter_verification_code": "Introduce el código de 6 dígitos de tu aplicación de autenticación para verificar la configuración.", + "verification_code": "Código de verificación", + "verify_and_enable": "Verificar y activar", + "back": "Volver", + "disable_title": "Desactivar autenticación de dos factores", + "disable_warning": "Desactivar 2FA hará tu cuenta menos segura. Necesitarás introducir tu contraseña para confirmar.", + "invalid_password": "Contraseña inválida. Por favor, inténtalo de nuevo.", + "invalid_code": "Código de verificación inválido. Por favor, inténtalo de nuevo.", + "enable_error": "Error al activar 2FA. Por favor, inténtalo de nuevo.", + "verify_error": "Error al verificar el código 2FA. Por favor, inténtalo de nuevo.", + "disable_error": "Error al desactivar 2FA. Por favor, inténtalo de nuevo.", + "backup_codes_title": "Códigos de respaldo", + "backup_codes_description": "Guarda estos códigos de respaldo en un lugar seguro. Puedes usarlos para acceder a tu cuenta si pierdes el acceso a tu aplicación de autenticación.", + "backup_codes_warning": "¡Cada código solo puede usarse una vez. Guárdalos de forma segura!", + "backup_codes_saved": "He guardado mis códigos de respaldo" + }, + "danger_zone": { + "title": "Zona de peligro", + "description": "Acciones irreversibles y destructivas", + "delete_account_title": "Eliminar cuenta", + "delete_account_description": "Una vez que elimines tu cuenta, no hay vuelta atrás. Esto eliminará permanentemente tu cuenta, todos tus secretos y todos los datos asociados. Esta acción no se puede deshacer.", + "delete_account_bullet1": "Todos tus secretos se eliminarán permanentemente", + "delete_account_bullet2": "Tus datos de cuenta se eliminarán de nuestros servidores", + "delete_account_bullet3": "Cualquier enlace secreto compartido dejará de ser válido", + "delete_account_bullet4": "Esta acción no se puede revertir", + "delete_account_confirm": "¿Estás seguro de que quieres eliminar tu cuenta? Esta acción no se puede deshacer.", + "delete_account_button": "Eliminar cuenta", + "deleting_account_button": "Eliminando cuenta..." + }, + "developer": { + "title": "Claves API", + "description": "Gestiona las claves API para acceso programático", + "create_key": "Crear clave", + "create_key_title": "Crear clave API", + "key_name": "Nombre de la clave", + "key_name_placeholder": "ej. Mi integración", + "expiration": "Expiración", + "never_expires": "Nunca expira", + "expires_30_days": "30 días", + "expires_90_days": "90 días", + "expires_1_year": "1 año", + "create_button": "Crear", + "name_required": "El nombre de la clave es obligatorio", + "create_error": "Error al crear la clave API", + "key_created": "¡Clave API creada!", + "key_warning": "Copia esta clave ahora. No podrás verla de nuevo.", + "dismiss": "He copiado la clave", + "no_keys": "Aún no hay claves API. Crea una para empezar.", + "created": "Creada", + "last_used": "Último uso", + "expires": "Expira", + "docs_hint": "Aprende a usar las claves API en la", + "api_docs": "Documentación API" + } + }, + "analytics_page": { + "title": "Analítica", + "description": "Rastrea la actividad y los conocimientos de tus secretos compartidos", + "time_range": { + "last_7_days": "Últimos 7 días", + "last_14_days": "Últimos 14 días", + "last_30_days": "Últimos 30 días" + }, + "total_secrets": "Total de secretos", + "from_last_period": "+{{percentage}}% desde el último período", + "total_views": "Total de vistas", + "avg_views_per_secret": "Promedio de vistas/secreto", + "active_secrets": "Secretos activos", + "daily_activity": { + "title": "Actividad diaria", + "description": "Secretos creados y vistas a lo largo del tiempo", + "secrets": "Secretos", + "views": "Vistas", + "secrets_created": "Secretos Creados", + "secret_views": "Vistas de Secretos", + "date": "Fecha", + "trend": "Cambio", + "vs_previous": "vs día anterior", + "no_data": "Aún no hay datos de actividad disponibles." + }, + "locale": "es-ES", + "top_countries": { + "title": "Países principales", + "description": "Dónde se están viendo tus secretos", + "views": "vistas" + }, + "secret_types": { + "title": "Tipos de secretos", + "description": "Distribución por nivel de protección", + "password_protected": "Protegido con contraseña", + "ip_restricted": "Restringido por IP", + "burn_after_time": "Autodestrucción por tiempo" + }, + "expiration_stats": { + "title": "Estadísticas de caducidad", + "description": "Cuánto tiempo suelen durar los secretos", + "one_hour": "1 Hora", + "one_day": "1 Día", + "one_week_plus": "1 Semana+" + }, + "visitor_analytics": { + "title": "Analíticas de visitantes", + "description": "Visitas de página y visitantes únicos", + "unique": "Únicos", + "views": "Visitas", + "date": "Fecha", + "trend": "Cambio", + "vs_previous": "vs día anterior", + "no_data": "Aún no hay datos de visitantes disponibles." + }, + "secret_requests": { + "total": "Solicitudes de secretos", + "fulfilled": "Solicitudes completadas" + }, + "loading": "Cargando analíticas...", + "no_permission": "No tienes permiso para ver las analíticas.", + "failed_to_fetch": "Error al obtener los datos de analíticas." + }, + "instance_page": { + "title": "Configuración de la instancia", + "description": "Configura tu instancia de paste.es", + "managed_mode": { + "title": "Modo administrado", + "description": "Esta instancia se administra mediante variables de entorno. La configuración es de solo lectura." + }, + "tabs": { + "general": "General", + "security": "Seguridad", + "organization": "Organización", + "webhook": "Webhooks", + "metrics": "Métricas" + }, + "system_status": { + "title": "Estado del sistema", + "description": "Métricas de salud y rendimiento de la instancia", + "version": "Versión", + "uptime": "Tiempo de actividad", + "memory": "Memoria", + "cpu_usage": "Uso de CPU" + }, + "general_settings": { + "title": "Configuración general", + "description": "Configuración básica de la instancia", + "instance_name_label": "Nombre de la instancia", + "logo_label": "Logo de la instancia", + "logo_upload": "Subir logo", + "logo_remove": "Eliminar logo", + "logo_hint": "PNG, JPEG, GIF, SVG o WebP. Máximo 512KB.", + "logo_alt": "Logo de la instancia", + "logo_invalid_type": "Tipo de archivo inválido. Por favor, sube una imagen PNG, JPEG, GIF, SVG o WebP.", + "logo_too_large": "El archivo es demasiado grande. El tamaño máximo es 512KB.", + "default_expiration_label": "Expiración predeterminada", + "max_secrets_per_user_label": "Máximo de secretos por usuario", + "max_secret_size_label": "Tamaño máximo del secreto (MB)", + "instance_description_label": "Descripción de la instancia", + "important_message_label": "Mensaje importante", + "important_message_placeholder": "Ingrese un mensaje importante para mostrar a todos los usuarios...", + "important_message_hint": "Este mensaje se mostrará como un banner de alerta en la página de inicio. Soporta formato markdown. Déjelo vacío para ocultar.", + "allow_registration_title": "Permitir registro", + "allow_registration_description": "Permitir que los nuevos usuarios se registren", + "email_verification_title": "Verificación de correo electrónico", + "email_verification_description": "Requerir verificación de correo electrónico" + }, + "saving_button": "Guardando...", + "save_settings_button": "Guardar configuración", + "security_settings": { + "title": "Configuración de seguridad", + "description": "Configurar controles de seguridad y acceso", + "rate_limiting_title": "Limitación de velocidad", + "rate_limiting_description": "Habilitar limitación de velocidad de solicitudes", + "max_password_attempts_label": "Intentos máximos de contraseña", + "session_timeout_label": "Tiempo de espera de sesión (horas)", + "allow_file_uploads_title": "Permitir subida de archivos", + "allow_file_uploads_description": "Permitir a los usuarios adjuntar archivos a los secretos" + }, + "email_settings": { + "title": "Configuración de correo electrónico", + "description": "Configurar SMTP y notificaciones por correo electrónico", + "smtp_host_label": "Host SMTP", + "smtp_port_label": "Puerto SMTP", + "username_label": "Nombre de usuario", + "password_label": "Contraseña" + }, + "database_info": { + "title": "Información de la base de datos", + "description": "Estado y estadísticas de la base de datos", + "stats_title": "Estadísticas de la base de datos", + "total_secrets": "Total de secretos:", + "total_users": "Total de usuarios:", + "disk_usage": "Uso del disco:", + "connection_status_title": "Estado de la conexión", + "connected": "Conectado", + "connected_description": "La base de datos está sana y responde normalmente" + }, + "system_info": { + "title": "Información del sistema", + "description": "Detalles del servidor y mantenimiento", + "system_info_title": "Información del sistema", + "version": "Versión:", + "uptime": "Tiempo de actividad:", + "status": "Estado:", + "resource_usage_title": "Uso de recursos", + "memory": "Memoria:", + "cpu": "CPU:", + "disk": "Disco:" + }, + "maintenance_actions": { + "title": "Acciones de mantenimiento", + "description": "Estas acciones pueden afectar la disponibilidad del sistema. Úselas con precaución.", + "restart_service_button": "Reiniciar servicio", + "clear_cache_button": "Borrar caché", + "export_logs_button": "Exportar registros" + } + }, + "secrets_page": { + "title": "Tus secretos", + "description": "Gestiona y monitoriza tus secretos compartidos", + "create_secret_button": "Crear secreto", + "search_placeholder": "Buscar secretos...", + "filter": { + "all_secrets": "Todos los secretos", + "active": "Activo", + "expired": "Caducado" + }, + "total_secrets": "Total de secretos", + "active_secrets": "Activo", + "expired_secrets": "Caducado", + "no_secrets_found_title": "No se encontraron secretos", + "no_secrets_found_description_filter": "Intenta ajustar tus criterios de búsqueda o filtro.", + "no_secrets_found_description_empty": "Crea tu primer secreto para empezar.", + "password_protected": "Contraseña", + "files": "archivos", + "table": { + "secret_header": "Secreto", + "created_header": "Creado", + "status_header": "Estado", + "views_header": "Vistas", + "actions_header": "Acciones", + "untitled_secret": "Secreto sin título", + "expired_status": "Caducado", + "active_status": "Activo", + "never_expires": "Nunca caduca", + "expired_time": "Caducado", + "copy_url_tooltip": "Copiar URL", + "open_secret_tooltip": "Abrir secreto", + "delete_secret_tooltip": "Eliminar secreto", + "views_left": "vistas restantes", + "delete_confirmation_title": "¿Estás seguro?", + "delete_confirmation_text": "Esta acción no se puede deshacer. Esto eliminará permanentemente el secreto.", + "delete_confirm_button": "Sí, eliminar", + "delete_cancel_button": "Cancelar" + } + }, + "users_page": { + "title": "Gestión de usuarios", + "description": "Gestiona usuarios y sus permisos", + "add_user_button": "Añadir usuario", + "search_placeholder": "Buscar usuarios...", + "filter": { + "all_roles": "Todos los roles", + "admin": "Administrador", + "user": "Usuario", + "all_status": "Todos los estados", + "active": "Activo", + "suspended": "Suspendido", + "pending": "Pendiente" + }, + "total_users": "Total de usuarios", + "active_users": "Activo", + "admins": "Administradores", + "pending_users": "Pendiente", + "no_users_found_title": "No se encontraron usuarios", + "no_users_found_description_filter": "Intenta ajustar tus criterios de búsqueda o filtro.", + "no_users_found_description_empty": "Aún no se han añadido usuarios.", + "table": { + "user_header": "Usuario", + "role_header": "Rol", + "status_header": "Estado", + "activity_header": "Actividad", + "last_login_header": "Último inicio de sesión", + "actions_header": "Acciones", + "created_at": "Fecha de creación" + }, + "status": { + "active": "Activo", + "banned": "Baneado" + }, + "delete_user_modal": { + "title": "Eliminar usuario", + "confirmation_message": "¿Estás seguro de que quieres eliminar al usuario {{username}}? Esta acción no se puede deshacer.", + "confirm_button": "Eliminar", + "cancel_button": "Cancelar" + }, + "edit_user_modal": { + "title": "Editar usuario: {{username}}", + "username_label": "Nombre de usuario", + "email_label": "Correo electrónico", + "role_label": "Rol", + "banned_label": "Baneado", + "save_button": "Guardar", + "cancel_button": "Cancelar" + }, + "add_user_modal": { + "title": "Añadir nuevo usuario", + "name_label": "Nombre", + "username_label": "Nombre de usuario", + "email_label": "Correo electrónico", + "password_label": "Contraseña", + "role_label": "Rol", + "save_button": "Añadir usuario", + "cancel_button": "Cancelar" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Volver a iniciar sesión", + "check_email_title": "Revisa tu correo electrónico", + "check_email_description": "Hemos enviado un enlace de restablecimiento de contraseña a {{email}}", + "did_not_receive_email": "¿No recibiste el correo electrónico? Revisa tu carpeta de spam o inténtalo de nuevo.", + "try_again_button": "Intentar de nuevo", + "forgot_password_title": "¿Olvidaste tu contraseña?", + "forgot_password_description": "No te preocupes, te enviaremos instrucciones para restablecerla", + "email_label": "Correo electrónico", + "email_placeholder": "Introduce tu correo electrónico", + "email_hint": "Introduce el correo electrónico asociado a tu cuenta", + "sending_button": "Enviando...", + "reset_password_button": "Restablecer contraseña", + "remember_password": "¿Recuerdas tu contraseña?", + "sign_in_link": "Iniciar sesión", + "unexpected_error": "Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo." + }, + "login_page": { + "back_to_hemmelig": "Volver a paste.es", + "welcome_back": "Bienvenido de nuevo a paste.es", + "welcome_back_title": "Bienvenido de nuevo", + "welcome_back_description": "Inicia sesión en tu cuenta de paste.es", + "username_label": "Nombre de usuario", + "username_placeholder": "Introduce tu nombre de usuario", + "password_label": "Contraseña", + "password_placeholder": "Introduce tu contraseña", + "forgot_password_link": "¿Olvidaste tu contraseña?", + "signing_in_button": "Iniciando sesión...", + "sign_in_button": "Iniciar sesión", + "or_continue_with": "O continuar con", + "continue_with_github": "Continuar con GitHub", + "no_account_question": "¿No tienes una cuenta?", + "sign_up_link": "Registrarse", + "unexpected_error": "Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo." + }, + "register_page": { + "back_to_hemmelig": "Volver a paste.es", + "join_hemmelig": "Únete a paste.es para compartir secretos de forma segura", + "email_password_disabled_message": "El registro con correo electrónico y contraseña está desactivado. Por favor, usa una de las opciones de inicio de sesión social a continuación.", + "create_account_title": "Crear cuenta", + "create_account_description": "Únete a paste.es para compartir secretos de forma segura", + "username_label": "Nombre de usuario", + "username_placeholder": "Elige un nombre de usuario", + "email_label": "Correo electrónico", + "email_placeholder": "Introduce tu correo electrónico", + "password_label": "Contraseña", + "password_placeholder": "Crea una contraseña", + "password_strength_label": "Fuerza de la contraseña", + "password_strength_levels": { + "very_weak": "Muy débil", + "weak": "Débil", + "fair": "Regular", + "good": "Buena", + "strong": "Fuerte" + }, + "confirm_password_label": "Confirma la contraseña", + "confirm_password_placeholder": "Confirma tu contraseña", + "passwords_match": "Las contraseñas coinciden", + "passwords_do_not_match": "Las contraseñas no coinciden", + "password_mismatch_alert": "Las contraseñas no coinciden", + "creating_account_button": "Creando cuenta...", + "create_account_button": "Crear cuenta", + "or_continue_with": "O continuar con", + "continue_with_github": "Continuar con GitHub", + "already_have_account_question": "¿Ya tienes una cuenta?", + "sign_in_link": "Iniciar sesión", + "invite_code_label": "Código de invitación", + "invite_code_placeholder": "Introduce tu código de invitación", + "invite_code_required": "Se requiere código de invitación", + "invalid_invite_code": "Código de invitación inválido", + "failed_to_validate_invite": "Error al validar el código de invitación", + "unexpected_error": "Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo.", + "email_domain_not_allowed": "Dominio de correo electrónico no permitido", + "account_already_exists": "Ya existe una cuenta con este correo electrónico. Por favor, inicia sesión." + }, + "secret_form": { + "failed_to_create_secret": "Error al crear el secreto: {{errorMessage}}", + "failed_to_upload_file": "Error al subir el archivo: {{fileName}}" + }, + "secret_page": { + "password_label": "Contraseña", + "password_placeholder": "Introduce la contraseña para ver el secreto", + "decryption_key_label": "Clave de descifrado", + "decryption_key_placeholder": "Introduce la clave de descifrado", + "view_secret_button": "Ver secreto", + "views_remaining_tooltip": "Vistas restantes: {{count}}", + "loading_message": "Descifrando secreto...", + "files_title": "Archivos adjuntos", + "secret_waiting_title": "Alguien ha compartido un secreto contigo", + "secret_waiting_description": "Este secreto está cifrado y solo se puede ver una vez que hagas clic en el botón de abajo.", + "one_view_remaining": "Este secreto solo se puede ver 1 vez más", + "views_remaining": "Este secreto se puede ver {{count}} veces más", + "view_warning": "Una vez visto, esta acción no se puede deshacer", + "secret_revealed": "Secreto", + "copy_secret": "Copiar al portapapeles", + "download": "Descargar", + "create_your_own": "Crea tu propio secreto", + "encrypted_secret": "Secreto cifrado", + "unlock_secret": "Desbloquear secreto", + "delete_secret": "Eliminar secreto", + "delete_modal_title": "Eliminar secreto", + "delete_modal_message": "¿Estás seguro de que quieres eliminar este secreto? Esta acción no se puede deshacer.", + "decryption_failed": "No se pudo descifrar el secreto. Por favor, verifica tu contraseña o clave de descifrado.", + "fetch_error": "Ocurrió un error al obtener el secreto. Por favor, inténtalo de nuevo." + }, + "expiration": { + "28_days": "28 Días", + "14_days": "14 Días", + "7_days": "7 Días", + "3_days": "3 Días", + "1_day": "1 Día", + "12_hours": "12 Horas", + "4_hours": "4 Horas", + "1_hour": "1 Hora", + "30_minutes": "30 Minutos", + "5_minutes": "5 Minutos" + }, + "error_display": { + "clear_errors_button_title": "Borrar errores" + }, + "secret_not_found_page": { + "title": "Secreto no encontrado", + "message": "El secreto que estás buscando no existe, ha caducado o ha sido quemado.", + "error_details": "Detalles del error:", + "go_home_button": "Ir a la página de inicio" + }, + "organization_page": { + "title": "Configuración de la organización", + "description": "Configura los ajustes y controles de acceso de toda la organización", + "registration_settings": { + "title": "Configuración de registro", + "description": "Controla cómo los usuarios pueden unirse a tu organización", + "invite_only_title": "Registro solo con invitación", + "invite_only_description": "Los usuarios solo pueden registrarse con un código de invitación válido", + "require_registered_user_title": "Solo usuarios registrados", + "require_registered_user_description": "Solo los usuarios registrados pueden crear secretos", + "disable_email_password_signup_title": "Desactivar registro por correo/contraseña", + "disable_email_password_signup_description": "Desactivar el registro con correo electrónico y contraseña (solo inicio de sesión social)", + "allowed_domains_title": "Dominios de correo permitidos", + "allowed_domains_description": "Solo permitir registro desde dominios de correo específicos (separados por comas, ej., empresa.com, org.net)", + "allowed_domains_placeholder": "empresa.com, org.net", + "allowed_domains_hint": "Lista de dominios de correo separados por comas. Deja vacío para permitir todos los dominios." + }, + "invite_codes": { + "title": "Códigos de invitación", + "description": "Crear y gestionar códigos de invitación para nuevos usuarios", + "create_invite_button": "Crear código de invitación", + "code_header": "Código", + "uses_header": "Usos", + "expires_header": "Expira", + "actions_header": "Acciones", + "unlimited": "Ilimitado", + "never": "Nunca", + "expired": "Expirado", + "no_invites": "Aún no hay códigos de invitación", + "no_invites_description": "Crea un código de invitación para permitir que nuevos usuarios se registren", + "copy_tooltip": "Copiar código", + "delete_tooltip": "Eliminar código" + }, + "create_invite_modal": { + "title": "Crear código de invitación", + "max_uses_label": "Usos máximos", + "max_uses_placeholder": "Deja vacío para ilimitado", + "expiration_label": "Expiración", + "expiration_options": { + "never": "Nunca", + "24_hours": "24 Horas", + "7_days": "7 Días", + "30_days": "30 Días" + }, + "cancel_button": "Cancelar", + "create_button": "Crear" + }, + "saving_button": "Guardando...", + "save_settings_button": "Guardar configuración" + }, + "invites_page": { + "title": "Códigos de invitación", + "description": "Gestiona los códigos de invitación para el registro de nuevos usuarios", + "create_invite_button": "Crear invitación", + "loading": "Cargando códigos de invitación...", + "table": { + "code_header": "Código", + "uses_header": "Usos", + "expires_header": "Expira", + "status_header": "Estado", + "never": "Nunca" + }, + "status": { + "active": "Activo", + "expired": "Expirado", + "used": "Usado", + "inactive": "Inactivo" + }, + "no_invites": "Aún no hay códigos de invitación", + "create_modal": { + "title": "Crear código de invitación", + "max_uses_label": "Usos máximos", + "expires_in_label": "Expira en (días)" + }, + "delete_modal": { + "title": "Desactivar código de invitación", + "confirm_text": "Desactivar", + "cancel_text": "Cancelar", + "message": "¿Estás seguro de que quieres desactivar el código de invitación {{code}}? Esta acción no se puede deshacer." + }, + "toast": { + "created": "Código de invitación creado", + "deactivated": "Código de invitación desactivado", + "copied": "Código de invitación copiado al portapapeles", + "fetch_error": "Error al obtener los códigos de invitación", + "create_error": "Error al crear el código de invitación", + "delete_error": "Error al desactivar el código de invitación" + } + }, + "social_login": { + "continue_with": "Continuar con {{provider}}", + "sign_up_with": "Registrarse con {{provider}}" + }, + "setup_page": { + "title": "Bienvenido a paste.es", + "description": "Crea tu cuenta de administrador para comenzar", + "name_label": "Nombre completo", + "name_placeholder": "Introduce tu nombre completo", + "username_label": "Nombre de usuario", + "username_placeholder": "Elige un nombre de usuario", + "email_label": "Dirección de correo electrónico", + "email_placeholder": "Introduce tu correo electrónico", + "password_label": "Contraseña", + "password_placeholder": "Crea una contraseña (mín. 8 caracteres)", + "confirm_password_label": "Confirmar contraseña", + "confirm_password_placeholder": "Confirma tu contraseña", + "create_admin": "Crear cuenta de administrador", + "creating": "Creando cuenta...", + "success": "¡Cuenta de administrador creada con éxito! Por favor, inicia sesión.", + "error": "Error al crear la cuenta de administrador", + "passwords_mismatch": "Las contraseñas no coinciden", + "password_too_short": "La contraseña debe tener al menos 8 caracteres", + "note": "Esta configuración solo se puede completar una vez. La cuenta de administrador tendrá acceso completo para gestionar esta instancia." + }, + "theme_toggle": { + "switch_to_light": "Cambiar a modo claro", + "switch_to_dark": "Cambiar a modo oscuro" + }, + "webhook_settings": { + "title": "Notificaciones de webhook", + "description": "Notificar a servicios externos cuando los secretos son vistos o quemados", + "enable_webhooks_title": "Habilitar webhooks", + "enable_webhooks_description": "Enviar solicitudes HTTP POST a tu URL de webhook cuando ocurran eventos", + "webhook_url_label": "URL del webhook", + "webhook_url_placeholder": "https://ejemplo.com/webhook", + "webhook_url_hint": "La URL donde se enviarán las cargas del webhook", + "webhook_secret_label": "Secreto del webhook", + "webhook_secret_placeholder": "Introduce un secreto para la firma HMAC", + "webhook_secret_hint": "Se usa para firmar las cargas del webhook con HMAC-SHA256. La firma se envía en el encabezado X-Paste-Signature.", + "events_title": "Eventos del webhook", + "on_view_title": "Secreto visto", + "on_view_description": "Enviar un webhook cuando se ve un secreto", + "on_burn_title": "Secreto quemado", + "on_burn_description": "Enviar un webhook cuando se quema o elimina un secreto" + }, + "metrics_settings": { + "title": "Métricas de Prometheus", + "description": "Exponer métricas para monitoreo con Prometheus", + "enable_metrics_title": "Habilitar métricas de Prometheus", + "enable_metrics_description": "Exponer un endpoint /api/metrics para scraping de Prometheus", + "metrics_secret_label": "Secreto de métricas", + "metrics_secret_placeholder": "Introduce un secreto para autenticación", + "metrics_secret_hint": "Se usa como token Bearer para autenticar solicitudes al endpoint de métricas. Deja vacío para sin autenticación (no recomendado).", + "endpoint_info_title": "Información del endpoint", + "endpoint_info_description": "Una vez habilitadas, las métricas estarán disponibles en:", + "endpoint_auth_hint": "Incluye el secreto como token Bearer en el encabezado Authorization al obtener métricas." + }, + "verify_2fa_page": { + "back_to_login": "Volver al inicio de sesión", + "title": "Autenticación de dos factores", + "description": "Introduce el código de 6 dígitos de tu aplicación de autenticación", + "enter_code_hint": "Introduce el código de tu aplicación de autenticación", + "verifying": "Verificando...", + "verify_button": "Verificar", + "invalid_code": "Código de verificación inválido. Por favor, inténtalo de nuevo.", + "unexpected_error": "Ha ocurrido un error inesperado. Por favor, inténtalo de nuevo." + }, + "common": { + "error": "Error", + "cancel": "Cancelar", + "confirm": "Confirmar", + "ok": "OK", + "delete": "Eliminar", + "deleting": "Eliminando...", + "loading": "Cargando..." + }, + "pagination": { + "showing": "Mostrando {{start}} a {{end}} de {{total}} resultados", + "previous_page": "Página anterior", + "next_page": "Página siguiente" + }, + "not_found_page": { + "title": "Página no encontrada", + "message": "Esta página se ha desvanecido en el aire, tal como lo hacen nuestros secretos.", + "hint": "La página que buscas no existe o ha sido movida.", + "go_home_button": "Ir al inicio", + "create_secret_button": "Crear secreto" + }, + "error_boundary": { + "title": "Algo salió mal", + "message": "Ocurrió un error inesperado al procesar tu solicitud.", + "hint": "No te preocupes, tus secretos siguen seguros. Intenta actualizar la página.", + "error_details": "Detalles del error:", + "unknown_error": "Ocurrió un error desconocido", + "try_again_button": "Intentar de nuevo", + "go_home_button": "Ir al inicio" + }, + "secret_requests_page": { + "title": "Solicitudes de secretos", + "description": "Solicita secretos de otros a través de enlaces seguros", + "create_request_button": "Crear solicitud", + "no_requests": "Aún no hay solicitudes de secretos. Crea una para comenzar.", + "table": { + "title_header": "Título", + "status_header": "Estado", + "secret_expiry_header": "Expiración del secreto", + "link_expires_header": "Expiración del enlace", + "copy_link_tooltip": "Copiar enlace de creador", + "view_secret_tooltip": "Ver secreto", + "cancel_tooltip": "Cancelar solicitud" + }, + "status": { + "pending": "Pendiente", + "fulfilled": "Completada", + "expired": "Expirada", + "cancelled": "Cancelada" + }, + "time": { + "days": "{{count}} día", + "days_plural": "{{count}} días", + "hours": "{{count}} hora", + "hours_plural": "{{count}} horas", + "minutes": "{{count}} minuto", + "minutes_plural": "{{count}} minutos" + }, + "link_modal": { + "title": "Enlace de creador", + "description": "Envía este enlace a la persona que debe proporcionar el secreto. Podrá ingresar y cifrar el secreto usando este enlace.", + "copy_button": "Copiar enlace", + "close_button": "Cerrar", + "warning": "Este enlace solo se puede usar una vez. Una vez que se envíe un secreto, el enlace ya no funcionará." + }, + "cancel_modal": { + "title": "Cancelar solicitud", + "message": "¿Estás seguro de que deseas cancelar la solicitud \"{{title}}\"? Esta acción no se puede deshacer.", + "confirm_text": "Cancelar solicitud", + "cancel_text": "Mantener solicitud" + }, + "toast": { + "copied": "Enlace copiado al portapapeles", + "cancelled": "Solicitud cancelada", + "fetch_error": "Error al obtener los detalles de la solicitud", + "cancel_error": "Error al cancelar la solicitud" + } + }, + "create_request_page": { + "title": "Crear solicitud de secreto", + "description": "Solicita un secreto de alguien generando un enlace seguro que pueda usar para enviarlo", + "back_button": "Volver a solicitudes de secretos", + "form": { + "title_label": "Título de la solicitud", + "title_placeholder": "ej., Credenciales AWS para Proyecto X", + "description_label": "Descripción (opcional)", + "description_placeholder": "Proporciona contexto adicional sobre lo que necesitas...", + "link_validity_label": "Validez del enlace", + "link_validity_hint": "Cuánto tiempo el enlace de creador permanecerá activo", + "secret_settings_title": "Configuración del secreto", + "secret_settings_description": "Esta configuración se aplicará al secreto una vez creado", + "secret_expiration_label": "Expiración del secreto", + "max_views_label": "Vistas máximas", + "password_label": "Protección con contraseña (opcional)", + "password_placeholder": "Ingresa una contraseña (mín. 5 caracteres)", + "password_hint": "Los destinatarios necesitarán esta contraseña para ver el secreto", + "ip_restriction_label": "Restricción de IP (opcional)", + "ip_restriction_placeholder": "192.168.1.0/24 o 203.0.113.5", + "prevent_burn_label": "Prevenir auto-destrucción (mantener el secreto después del máximo de vistas)", + "webhook_title": "Notificación webhook (opcional)", + "webhook_description": "Recibe una notificación cuando se envíe el secreto", + "webhook_url_label": "URL del webhook", + "webhook_url_placeholder": "https://tu-servidor.com/webhook", + "webhook_url_hint": "Se recomienda HTTPS. Se enviará una notificación cuando se cree el secreto.", + "creating_button": "Creando...", + "create_button": "Crear solicitud" + }, + "validity": { + "30_days": "30 días", + "14_days": "14 días", + "7_days": "7 días", + "3_days": "3 días", + "1_day": "1 día", + "12_hours": "12 horas", + "1_hour": "1 hora" + }, + "success": { + "title": "¡Solicitud creada!", + "description": "Comparte el enlace de creador con la persona que debe proporcionar el secreto", + "creator_link_label": "Enlace de creador", + "webhook_secret_label": "Secreto del webhook", + "webhook_secret_warning": "¡Guarda este secreto ahora! No se mostrará de nuevo. Úsalo para verificar las firmas del webhook.", + "expires_at": "El enlace expira: {{date}}", + "create_another_button": "Crear otra solicitud", + "view_all_button": "Ver todas las solicitudes" + }, + "toast": { + "created": "Solicitud de secreto creada con éxito", + "create_error": "Error al crear la solicitud de secreto", + "copied": "Copiado al portapapeles" + } + }, + "request_secret_page": { + "loading": "Cargando solicitud...", + "error": { + "title": "Solicitud no disponible", + "invalid_link": "Este enlace es inválido o ha sido manipulado.", + "not_found": "Esta solicitud no fue encontrada o el enlace es inválido.", + "already_fulfilled": "Esta solicitud ya ha sido completada o ha expirado.", + "generic": "Ocurrió un error al cargar la solicitud.", + "go_home_button": "Ir al inicio" + }, + "form": { + "title": "Enviar un secreto", + "description": "Alguien te ha pedido que compartas un secreto de forma segura", + "password_protected_note": "Este secreto estará protegido con contraseña", + "encryption_note": "Tu secreto será cifrado en tu navegador antes de ser enviado. La clave de descifrado solo se incluirá en la URL final que compartas.", + "submitting_button": "Cifrando y enviando...", + "submit_button": "Enviar secreto" + }, + "success": { + "title": "¡Secreto creado!", + "description": "Tu secreto ha sido cifrado y almacenado de forma segura", + "decryption_key_label": "Clave de descifrado", + "warning": "Importante: ¡Copia esta clave de descifrado ahora y envíala al solicitante. Esta es la única vez que la verás!", + "manual_send_note": "Debes enviar manualmente esta clave de descifrado a la persona que solicitó el secreto. Ya tienen la URL del secreto en su panel de control.", + "create_own_button": "Crear tu propio secreto" + }, + "toast": { + "created": "Secreto enviado con éxito", + "create_error": "Error al enviar el secreto", + "copied": "Copiado al portapapeles" + } + } +} diff --git a/src/i18n/locales/fr/fr.json b/src/i18n/locales/fr/fr.json new file mode 100644 index 0000000..43c61c6 --- /dev/null +++ b/src/i18n/locales/fr/fr.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Modèles", + "description": "Démarrage rapide avec un modèle", + "templates": { + "credentials": "Identifiants de connexion", + "api_key": "Clé API", + "database": "Base de données", + "server": "Accès serveur", + "credit_card": "Carte de paiement", + "email": "Compte e-mail" + } + }, + "editor": { + "tooltips": { + "copy_text": "Copier en texte brut", + "copy_html": "Copier en HTML", + "copy_base64": "Copier en Base64", + "bold": "Gras", + "italic": "Italique", + "strikethrough": "Barré", + "inline_code": "Code en ligne", + "link": "Lien", + "remove_link": "Supprimer le lien", + "insert_password": "Insérer un mot de passe", + "paragraph": "Paragraphe", + "heading1": "Titre 1", + "heading2": "Titre 2", + "heading3": "Titre 3", + "bullet_list": "Liste à puces", + "numbered_list": "Liste numérotée", + "blockquote": "Citation", + "code_block": "Bloc de code", + "undo": "Annuler", + "redo": "Rétablir" + }, + "copy_success": { + "html": "HTML copié !", + "text": "Texte copié !", + "base64": "Base64 copié !" + }, + "link_modal": { + "title": "Ajouter un lien", + "url_label": "URL", + "url_placeholder": "Entrer l'URL", + "cancel": "Annuler", + "update": "Mettre à jour", + "insert": "Insérer" + }, + "password_modal": { + "title": "Générer un mot de passe", + "length_label": "Longueur du mot de passe", + "options_label": "Options", + "include_numbers": "Chiffres", + "include_symbols": "Symboles", + "include_uppercase": "Majuscules", + "include_lowercase": "Minuscules", + "generated_password": "Mot de passe généré", + "refresh": "Actualiser", + "cancel": "Annuler", + "insert": "Insérer", + "copied_and_added": "Mot de passe ajouté et copié dans le presse-papiers", + "added": "Mot de passe ajouté" + }, + "formatting_tools": "Outils de formatage", + "character_count": "caractères" + }, + "create_button": { + "creating_secret": "Création du secret...", + "create": "Créer" + }, + "file_upload": { + "sign_in_to_upload": "Connectez-vous pour téléverser des fichiers", + "sign_in": "Se connecter", + "drop_files_here": "Déposez les fichiers ici", + "drag_and_drop": "Glissez-déposez un fichier ou cliquez pour sélectionner", + "uploading": "Téléversement...", + "upload_file": "Téléverser un fichier", + "file_too_large": "Le fichier \"{{fileName}}\" ({{fileSize}} Mo) dépasse la taille maximale de {{maxSize}} Mo", + "max_size_exceeded": "La taille totale des fichiers dépasse le maximum de {{maxSize}} Mo" + }, + "footer": { + "tagline": "Hemmelig, [heˈm(ɛ)li], signifie 'secret' en norvégien", + "privacy": "Confidentialité", + "terms": "Conditions", + "api": "API", + "managed_hosting": "Hébergement géré", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Accueil", + "sign_in": "Se connecter", + "sign_up": "S'inscrire", + "dashboard": "Tableau de bord", + "hero_text_part1": "Partagez des secrets en toute sécurité avec des messages chiffrés qui", + "hero_text_part2": " s'autodétruisent", + "hero_text_part3": " automatiquement après lecture." + }, + "dashboard_layout": { + "secrets": "Secrets", + "secret_requests": "Demandes de secrets", + "account": "Compte", + "analytics": "Analytiques", + "users": "Utilisateurs", + "invites": "Invitations", + "instance": "Instance", + "sign_out": "Se déconnecter", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Secret créé !", + "secret_created_description": "Votre secret est maintenant disponible à l'URL suivante. Conservez votre clé de déchiffrement en lieu sûr, car elle ne peut pas être récupérée.", + "secret_url_label": "URL du secret", + "decryption_key_label": "Clé de déchiffrement", + "password_label": "Mot de passe", + "create_new_secret_button": "Créer un nouveau secret", + "copy_url_button": "Copier l'URL", + "burn_secret_button": "Détruire le secret", + "max_secrets_per_user_info": "Vous pouvez créer jusqu'à {{count}} secrets.", + "failed_to_burn": "Échec de la destruction du secret. Veuillez réessayer." + }, + "security_settings": { + "security_title": "Sécurité", + "security_description": "Configurez les paramètres de sécurité pour votre secret", + "remember_settings": "Mémoriser", + "private_title": "Privé", + "private_description": "Les secrets privés sont chiffrés et ne peuvent être consultés qu'avec la clé de déchiffrement et/ou le mot de passe.", + "expiration_title": "Expiration", + "expiration_burn_after_time_description": "Définissez quand le secret doit être détruit", + "expiration_default_description": "Définissez la durée de disponibilité du secret", + "max_views_title": "Vues maximales", + "burn_after_time_mode_title": "Mode destruction programmée", + "burn_after_time_mode_description": "Le secret sera détruit après l'expiration du délai, quel que soit le nombre de consultations.", + "password_protection_title": "Protection par mot de passe", + "password_protection_description": "Ajoutez une couche de sécurité supplémentaire avec un mot de passe", + "enter_password_label": "Entrer le mot de passe", + "password_placeholder": "Entrez un mot de passe sécurisé...", + "password_hint": "Minimum 5 caractères. Les destinataires auront besoin de ce mot de passe pour voir le secret", + "password_error": "Le mot de passe doit contenir au moins 5 caractères", + "ip_restriction_title": "Restriction par IP ou CIDR", + "ip_restriction_description": "L'entrée CIDR permettra aux utilisateurs de spécifier les plages d'adresses IP pouvant accéder au secret.", + "ip_address_cidr_label": "Adresse IP ou plage CIDR", + "ip_address_cidr_placeholder": "192.168.1.0/24 ou 203.0.113.5", + "ip_address_cidr_hint": "Seules les requêtes provenant de ces adresses IP pourront accéder au secret", + "burn_after_time_title": "Détruire après expiration", + "burn_after_time_description": "Détruire le secret uniquement après l'expiration du délai" + }, + "title_field": { + "placeholder": "Titre", + "hint": "Donnez un titre mémorable à votre secret (optionnel)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "vues" + }, + "account_page": { + "title": "Paramètres du compte", + "description": "Gérez vos préférences de compte et votre sécurité", + "tabs": { + "profile": "Profil", + "security": "Sécurité", + "developer": "Développeur", + "danger_zone": "Zone de danger" + }, + "profile_info": { + "title": "Informations du profil", + "description": "Mettez à jour vos informations personnelles", + "first_name_label": "Prénom", + "last_name_label": "Nom", + "username_label": "Nom d'utilisateur", + "email_label": "Adresse e-mail", + "saving_button": "Enregistrement...", + "save_changes_button": "Enregistrer les modifications" + }, + "profile_settings": { + "username_taken": "Ce nom d'utilisateur est déjà pris" + }, + "security_settings": { + "title": "Paramètres de sécurité", + "description": "Gérez votre mot de passe et vos préférences de sécurité", + "change_password_title": "Changer le mot de passe", + "current_password_label": "Mot de passe actuel", + "current_password_placeholder": "Entrez le mot de passe actuel", + "new_password_label": "Nouveau mot de passe", + "new_password_placeholder": "Entrez le nouveau mot de passe", + "confirm_new_password_label": "Confirmer le nouveau mot de passe", + "confirm_new_password_placeholder": "Confirmez le nouveau mot de passe", + "password_mismatch_alert": "Les nouveaux mots de passe ne correspondent pas", + "changing_password_button": "Modification...", + "change_password_button": "Changer le mot de passe", + "password_change_success": "Mot de passe modifié avec succès !", + "password_change_error": "Échec du changement de mot de passe. Veuillez réessayer." + }, + "two_factor": { + "title": "Authentification à deux facteurs", + "description": "Ajoutez une couche de sécurité supplémentaire à votre compte", + "enabled": "Activée", + "disabled": "Non activée", + "setup_button": "Configurer 2FA", + "disable_button": "Désactiver 2FA", + "enter_password_to_enable": "Entrez votre mot de passe pour activer l'authentification à deux facteurs.", + "continue": "Continuer", + "scan_qr_code": "Scannez ce code QR avec votre application d'authentification (Google Authenticator, Authy, etc.).", + "manual_entry_hint": "Ou entrez manuellement ce code dans votre application d'authentification :", + "enter_verification_code": "Entrez le code à 6 chiffres de votre application d'authentification pour vérifier la configuration.", + "verification_code": "Code de vérification", + "verify_and_enable": "Vérifier et activer", + "back": "Retour", + "disable_title": "Désactiver l'authentification à deux facteurs", + "disable_warning": "Désactiver 2FA rendra votre compte moins sécurisé. Vous devrez entrer votre mot de passe pour confirmer.", + "invalid_password": "Mot de passe invalide. Veuillez réessayer.", + "invalid_code": "Code de vérification invalide. Veuillez réessayer.", + "enable_error": "Échec de l'activation de 2FA. Veuillez réessayer.", + "verify_error": "Échec de la vérification du code 2FA. Veuillez réessayer.", + "disable_error": "Échec de la désactivation de 2FA. Veuillez réessayer.", + "backup_codes_title": "Codes de secours", + "backup_codes_description": "Sauvegardez ces codes de secours dans un endroit sûr. Vous pouvez les utiliser pour accéder à votre compte si vous perdez l'accès à votre application d'authentification.", + "backup_codes_warning": "Chaque code ne peut être utilisé qu'une seule fois. Conservez-les en sécurité !", + "backup_codes_saved": "J'ai sauvegardé mes codes de secours" + }, + "danger_zone": { + "title": "Zone de danger", + "description": "Actions irréversibles et destructrices", + "delete_account_title": "Supprimer le compte", + "delete_account_description": "Une fois votre compte supprimé, il n'y a pas de retour en arrière. Cela supprimera définitivement votre compte, tous vos secrets et toutes les données associées. Cette action est irréversible.", + "delete_account_bullet1": "Tous vos secrets seront définitivement supprimés", + "delete_account_bullet2": "Vos données de compte seront supprimées de nos serveurs", + "delete_account_bullet3": "Tous les liens de secrets partagés deviendront invalides", + "delete_account_bullet4": "Cette action est irréversible", + "delete_account_confirm": "Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.", + "delete_account_button": "Supprimer le compte", + "deleting_account_button": "Suppression du compte..." + }, + "developer": { + "title": "Clés API", + "description": "Gérer les clés API pour l'accès programmatique", + "create_key": "Créer une clé", + "create_key_title": "Créer une clé API", + "key_name": "Nom de la clé", + "key_name_placeholder": "ex. Mon intégration", + "expiration": "Expiration", + "never_expires": "N'expire jamais", + "expires_30_days": "30 jours", + "expires_90_days": "90 jours", + "expires_1_year": "1 an", + "create_button": "Créer", + "name_required": "Le nom de la clé est requis", + "create_error": "Échec de la création de la clé API", + "key_created": "Clé API créée !", + "key_warning": "Copiez cette clé maintenant. Vous ne pourrez plus la voir.", + "dismiss": "J'ai copié la clé", + "no_keys": "Pas encore de clés API. Créez-en une pour commencer.", + "created": "Créée", + "last_used": "Dernière utilisation", + "expires": "Expire", + "docs_hint": "Apprenez à utiliser les clés API dans la", + "api_docs": "Documentation API" + } + }, + "analytics_page": { + "title": "Analytiques", + "description": "Suivez l'activité et les statistiques de vos secrets partagés", + "time_range": { + "last_7_days": "7 derniers jours", + "last_14_days": "14 derniers jours", + "last_30_days": "30 derniers jours" + }, + "total_secrets": "Total des secrets", + "from_last_period": "+{{percentage}}% depuis la dernière période", + "total_views": "Total des vues", + "avg_views_per_secret": "Moy. vues/secret", + "active_secrets": "Secrets actifs", + "daily_activity": { + "title": "Activité quotidienne", + "description": "Secrets créés et vues au fil du temps", + "secrets": "Secrets", + "views": "Vues", + "secrets_created": "Secrets Créés", + "secret_views": "Vues des Secrets", + "date": "Date", + "trend": "Variation", + "vs_previous": "vs jour précédent", + "no_data": "Aucune donnée d'activité disponible pour le moment." + }, + "locale": "fr-FR", + "top_countries": { + "title": "Principaux pays", + "description": "Où vos secrets sont consultés", + "views": "vues" + }, + "secret_types": { + "title": "Types de secrets", + "description": "Distribution par niveau de protection", + "password_protected": "Protégé par mot de passe", + "ip_restricted": "Restriction IP", + "burn_after_time": "Destruction programmée" + }, + "expiration_stats": { + "title": "Statistiques d'expiration", + "description": "Durée de vie typique des secrets", + "one_hour": "1 Heure", + "one_day": "1 Jour", + "one_week_plus": "1 Semaine+" + }, + "visitor_analytics": { + "title": "Analytiques des visiteurs", + "description": "Pages vues et visiteurs uniques", + "unique": "Uniques", + "views": "Vues", + "date": "Date", + "trend": "Variation", + "vs_previous": "vs jour précédent", + "no_data": "Aucune donnée de visiteur disponible pour le moment." + }, + "secret_requests": { + "total": "Demandes de secrets", + "fulfilled": "Demandes accomplies" + }, + "loading": "Chargement des analytiques...", + "no_permission": "Vous n'avez pas la permission de voir les analytiques.", + "failed_to_fetch": "Échec de la récupération des données analytiques." + }, + "instance_page": { + "title": "Paramètres de l'instance", + "description": "Configurez votre instance Hemmelig", + "managed_mode": { + "title": "Mode géré", + "description": "Cette instance est gérée via des variables d'environnement. Les paramètres sont en lecture seule." + }, + "tabs": { + "general": "Général", + "security": "Sécurité", + "organization": "Organisation", + "webhook": "Webhooks", + "metrics": "Métriques" + }, + "system_status": { + "title": "État du système", + "description": "Santé de l'instance et métriques de performance", + "version": "Version", + "uptime": "Temps de fonctionnement", + "memory": "Mémoire", + "cpu_usage": "Utilisation CPU" + }, + "general_settings": { + "title": "Paramètres généraux", + "description": "Configuration de base de l'instance", + "instance_name_label": "Nom de l'instance", + "logo_label": "Logo de l'instance", + "logo_upload": "Télécharger le logo", + "logo_remove": "Supprimer le logo", + "logo_hint": "PNG, JPEG, GIF, SVG ou WebP. Max 512 Ko.", + "logo_alt": "Logo de l'instance", + "logo_invalid_type": "Type de fichier invalide. Veuillez télécharger une image PNG, JPEG, GIF, SVG ou WebP.", + "logo_too_large": "Le fichier est trop volumineux. Taille maximale : 512 Ko.", + "default_expiration_label": "Expiration par défaut", + "max_secrets_per_user_label": "Max secrets par utilisateur", + "max_secret_size_label": "Taille max du secret (Mo)", + "instance_description_label": "Description de l'instance", + "important_message_label": "Message important", + "important_message_placeholder": "Entrez un message important à afficher à tous les utilisateurs...", + "important_message_hint": "Ce message sera affiché comme bannière d'alerte sur la page d'accueil. Prend en charge le formatage markdown. Laissez vide pour masquer.", + "allow_registration_title": "Autoriser l'inscription", + "allow_registration_description": "Permettre aux nouveaux utilisateurs de s'inscrire", + "email_verification_title": "Vérification e-mail", + "email_verification_description": "Exiger la vérification de l'e-mail" + }, + "saving_button": "Enregistrement...", + "save_settings_button": "Enregistrer les paramètres", + "security_settings": { + "title": "Paramètres de sécurité", + "description": "Configurer la sécurité et les contrôles d'accès", + "rate_limiting_title": "Limitation de débit", + "rate_limiting_description": "Activer la limitation du débit des requêtes", + "max_password_attempts_label": "Max tentatives de mot de passe", + "session_timeout_label": "Délai d'expiration de session (heures)", + "allow_file_uploads_title": "Autoriser le téléchargement de fichiers", + "allow_file_uploads_description": "Permettre aux utilisateurs de joindre des fichiers aux secrets" + }, + "email_settings": { + "title": "Paramètres e-mail", + "description": "Configurer SMTP et les notifications par e-mail", + "smtp_host_label": "Hôte SMTP", + "smtp_port_label": "Port SMTP", + "username_label": "Nom d'utilisateur", + "password_label": "Mot de passe" + }, + "database_info": { + "title": "Informations de la base de données", + "description": "État et statistiques de la base de données", + "stats_title": "Statistiques de la base de données", + "total_secrets": "Total des secrets :", + "total_users": "Total des utilisateurs :", + "disk_usage": "Utilisation disque :", + "connection_status_title": "État de la connexion", + "connected": "Connecté", + "connected_description": "La base de données est saine et répond normalement" + }, + "system_info": { + "title": "Informations système", + "description": "Détails du serveur et maintenance", + "system_info_title": "Info système", + "version": "Version :", + "uptime": "Temps de fonctionnement :", + "status": "État :", + "resource_usage_title": "Utilisation des ressources", + "memory": "Mémoire :", + "cpu": "CPU :", + "disk": "Disque :" + }, + "maintenance_actions": { + "title": "Actions de maintenance", + "description": "Ces actions peuvent affecter la disponibilité du système. À utiliser avec précaution.", + "restart_service_button": "Redémarrer le service", + "clear_cache_button": "Vider le cache", + "export_logs_button": "Exporter les journaux" + } + }, + "secrets_page": { + "title": "Vos secrets", + "description": "Gérez et surveillez vos secrets partagés", + "create_secret_button": "Créer un secret", + "search_placeholder": "Rechercher des secrets...", + "filter": { + "all_secrets": "Tous les secrets", + "active": "Actif", + "expired": "Expiré" + }, + "total_secrets": "Total des secrets", + "active_secrets": "Actifs", + "expired_secrets": "Expirés", + "no_secrets_found_title": "Aucun secret trouvé", + "no_secrets_found_description_filter": "Essayez d'ajuster vos critères de recherche ou de filtre.", + "no_secrets_found_description_empty": "Créez votre premier secret pour commencer.", + "password_protected": "Mot de passe", + "files": "fichiers", + "table": { + "secret_header": "Secret", + "created_header": "Créé", + "status_header": "État", + "views_header": "Vues", + "actions_header": "Actions", + "untitled_secret": "Secret sans titre", + "expired_status": "Expiré", + "active_status": "Actif", + "never_expires": "N'expire jamais", + "expired_time": "Expiré", + "views_left": "vues restantes", + "copy_url_tooltip": "Copier l'URL", + "open_secret_tooltip": "Ouvrir le secret", + "delete_secret_tooltip": "Supprimer le secret", + "delete_confirmation_title": "Êtes-vous sûr ?", + "delete_confirmation_text": "Cette action est irréversible. Le secret sera définitivement supprimé.", + "delete_confirm_button": "Oui, supprimer", + "delete_cancel_button": "Annuler" + } + }, + "users_page": { + "title": "Gestion des utilisateurs", + "description": "Gérez les utilisateurs et leurs permissions", + "add_user_button": "Ajouter un utilisateur", + "search_placeholder": "Rechercher des utilisateurs...", + "filter": { + "all_roles": "Tous les rôles", + "admin": "Administrateur", + "user": "Utilisateur", + "all_status": "Tous les états", + "active": "Actif", + "suspended": "Suspendu", + "pending": "En attente" + }, + "total_users": "Total des utilisateurs", + "active_users": "Actifs", + "admins": "Administrateurs", + "pending_users": "En attente", + "no_users_found_title": "Aucun utilisateur trouvé", + "no_users_found_description_filter": "Essayez d'ajuster vos critères de recherche ou de filtre.", + "no_users_found_description_empty": "Aucun utilisateur n'a encore été ajouté.", + "table": { + "user_header": "Utilisateur", + "role_header": "Rôle", + "status_header": "État", + "activity_header": "Activité", + "last_login_header": "Dernière connexion", + "actions_header": "Actions", + "created_at": "Créé le" + }, + "status": { + "active": "Actif", + "banned": "Banni" + }, + "delete_user_modal": { + "title": "Supprimer l'utilisateur", + "confirmation_message": "Êtes-vous sûr de vouloir supprimer l'utilisateur {{username}} ? Cette action est irréversible.", + "confirm_button": "Supprimer", + "cancel_button": "Annuler" + }, + "edit_user_modal": { + "title": "Modifier l'utilisateur : {{username}}", + "username_label": "Nom d'utilisateur", + "email_label": "E-mail", + "role_label": "Rôle", + "banned_label": "Banni", + "save_button": "Enregistrer", + "cancel_button": "Annuler" + }, + "add_user_modal": { + "title": "Ajouter un nouvel utilisateur", + "name_label": "Nom", + "username_label": "Nom d'utilisateur", + "email_label": "E-mail", + "password_label": "Mot de passe", + "role_label": "Rôle", + "save_button": "Ajouter l'utilisateur", + "cancel_button": "Annuler" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Retour à la connexion", + "check_email_title": "Vérifiez votre e-mail", + "check_email_description": "Nous avons envoyé un lien de réinitialisation de mot de passe à {{email}}", + "did_not_receive_email": "Vous n'avez pas reçu l'e-mail ? Vérifiez votre dossier spam ou réessayez.", + "try_again_button": "Réessayer", + "forgot_password_title": "Mot de passe oublié ?", + "forgot_password_description": "Pas de souci, nous vous enverrons des instructions de réinitialisation", + "email_label": "E-mail", + "email_placeholder": "Entrez votre e-mail", + "email_hint": "Entrez l'e-mail associé à votre compte", + "sending_button": "Envoi...", + "reset_password_button": "Réinitialiser le mot de passe", + "remember_password": "Vous vous souvenez de votre mot de passe ?", + "sign_in_link": "Se connecter", + "unexpected_error": "Une erreur inattendue s'est produite. Veuillez réessayer." + }, + "login_page": { + "back_to_hemmelig": "Retour à Hemmelig", + "welcome_back": "Bon retour sur Hemmelig", + "welcome_back_title": "Bon retour", + "welcome_back_description": "Connectez-vous à votre compte Hemmelig", + "username_label": "Nom d'utilisateur", + "username_placeholder": "Entrez votre nom d'utilisateur", + "password_label": "Mot de passe", + "password_placeholder": "Entrez votre mot de passe", + "forgot_password_link": "Mot de passe oublié ?", + "signing_in_button": "Connexion...", + "sign_in_button": "Se connecter", + "or_continue_with": "Ou continuer avec", + "continue_with_github": "Continuer avec GitHub", + "no_account_question": "Vous n'avez pas de compte ?", + "sign_up_link": "S'inscrire", + "unexpected_error": "Une erreur inattendue s'est produite. Veuillez réessayer." + }, + "register_page": { + "back_to_hemmelig": "Retour à Hemmelig", + "join_hemmelig": "Rejoignez Hemmelig pour partager des secrets en toute sécurité", + "email_password_disabled_message": "L'inscription par e-mail et mot de passe est désactivée. Veuillez utiliser l'une des options de connexion sociale ci-dessous.", + "create_account_title": "Créer un compte", + "create_account_description": "Rejoignez Hemmelig pour partager des secrets en toute sécurité", + "username_label": "Nom d'utilisateur", + "username_placeholder": "Choisissez un nom d'utilisateur", + "email_label": "E-mail", + "email_placeholder": "Entrez votre e-mail", + "password_label": "Mot de passe", + "password_placeholder": "Créez un mot de passe", + "password_strength_label": "Force du mot de passe", + "password_strength_levels": { + "very_weak": "Très faible", + "weak": "Faible", + "fair": "Moyen", + "good": "Bon", + "strong": "Fort" + }, + "confirm_password_label": "Confirmer le mot de passe", + "confirm_password_placeholder": "Confirmez votre mot de passe", + "passwords_match": "Les mots de passe correspondent", + "passwords_do_not_match": "Les mots de passe ne correspondent pas", + "password_mismatch_alert": "Les mots de passe ne correspondent pas", + "creating_account_button": "Création du compte...", + "create_account_button": "Créer un compte", + "or_continue_with": "Ou continuer avec", + "continue_with_github": "Continuer avec GitHub", + "already_have_account_question": "Vous avez déjà un compte ?", + "sign_in_link": "Se connecter", + "invite_code_label": "Code d'invitation", + "invite_code_placeholder": "Entrez votre code d'invitation", + "invite_code_required": "Le code d'invitation est requis", + "invalid_invite_code": "Code d'invitation invalide", + "failed_to_validate_invite": "Échec de la validation du code d'invitation", + "unexpected_error": "Une erreur inattendue s'est produite. Veuillez réessayer.", + "email_domain_not_allowed": "Domaine e-mail non autorisé", + "account_already_exists": "Un compte avec cet e-mail existe déjà. Veuillez vous connecter." + }, + "secret_form": { + "failed_to_create_secret": "Échec de la création du secret : {{errorMessage}}", + "failed_to_upload_file": "Échec du téléversement du fichier : {{fileName}}" + }, + "secret_page": { + "password_label": "Mot de passe", + "password_placeholder": "Entrez le mot de passe pour voir le secret", + "decryption_key_label": "Clé de déchiffrement", + "decryption_key_placeholder": "Entrez la clé de déchiffrement", + "view_secret_button": "Voir le secret", + "views_remaining_tooltip": "Vues restantes : {{count}}", + "loading_message": "Déchiffrement du secret...", + "files_title": "Fichiers joints", + "secret_waiting_title": "Quelqu'un a partagé un secret avec vous", + "secret_waiting_description": "Ce secret est chiffré et ne peut être consulté qu'en cliquant sur le bouton ci-dessous.", + "one_view_remaining": "Ce secret ne peut être consulté qu'une seule fois de plus", + "views_remaining": "Ce secret peut être consulté {{count}} fois de plus", + "view_warning": "Une fois consulté, cette action est irréversible", + "secret_revealed": "Secret", + "copy_secret": "Copier dans le presse-papiers", + "download": "Télécharger", + "create_your_own": "Créez votre propre secret", + "encrypted_secret": "Secret chiffré", + "unlock_secret": "Déverrouiller le secret", + "delete_secret": "Supprimer le secret", + "delete_modal_title": "Supprimer le secret", + "delete_modal_message": "Êtes-vous sûr de vouloir supprimer ce secret ? Cette action est irréversible.", + "decryption_failed": "Échec du déchiffrement du secret. Veuillez vérifier votre mot de passe ou votre clé de déchiffrement.", + "fetch_error": "Une erreur s'est produite lors de la récupération du secret. Veuillez réessayer." + }, + "expiration": { + "28_days": "28 Jours", + "14_days": "14 Jours", + "7_days": "7 Jours", + "3_days": "3 Jours", + "1_day": "1 Jour", + "12_hours": "12 Heures", + "4_hours": "4 Heures", + "1_hour": "1 Heure", + "30_minutes": "30 Minutes", + "5_minutes": "5 Minutes" + }, + "error_display": { + "clear_errors_button_title": "Effacer les erreurs" + }, + "secret_not_found_page": { + "title": "Secret non trouvé", + "message": "Le secret que vous recherchez n'existe pas, a expiré ou a été détruit.", + "error_details": "Détails de l'erreur :", + "go_home_button": "Aller à l'accueil" + }, + "organization_page": { + "title": "Paramètres de l'organisation", + "description": "Configurez les paramètres et contrôles d'accès de l'organisation", + "registration_settings": { + "title": "Paramètres d'inscription", + "description": "Contrôlez comment les utilisateurs peuvent rejoindre votre organisation", + "invite_only_title": "Inscription sur invitation uniquement", + "invite_only_description": "Les utilisateurs ne peuvent s'inscrire qu'avec un code d'invitation valide", + "require_registered_user_title": "Utilisateurs enregistrés uniquement", + "require_registered_user_description": "Seuls les utilisateurs enregistrés peuvent créer des secrets", + "disable_email_password_signup_title": "Désactiver l'inscription par e-mail/mot de passe", + "disable_email_password_signup_description": "Désactiver l'inscription avec e-mail et mot de passe (connexion sociale uniquement)", + "allowed_domains_title": "Domaines e-mail autorisés", + "allowed_domains_description": "Autoriser uniquement l'inscription depuis des domaines e-mail spécifiques (séparés par des virgules, ex. : entreprise.com, org.net)", + "allowed_domains_placeholder": "entreprise.com, org.net", + "allowed_domains_hint": "Liste de domaines e-mail séparés par des virgules. Laissez vide pour autoriser tous les domaines." + }, + "invite_codes": { + "title": "Codes d'invitation", + "description": "Créez et gérez les codes d'invitation pour les nouveaux utilisateurs", + "create_invite_button": "Créer un code d'invitation", + "code_header": "Code", + "uses_header": "Utilisations", + "expires_header": "Expire", + "actions_header": "Actions", + "unlimited": "Illimité", + "never": "Jamais", + "expired": "Expiré", + "no_invites": "Aucun code d'invitation pour l'instant", + "no_invites_description": "Créez un code d'invitation pour permettre aux nouveaux utilisateurs de s'inscrire", + "copy_tooltip": "Copier le code", + "delete_tooltip": "Supprimer le code" + }, + "create_invite_modal": { + "title": "Créer un code d'invitation", + "max_uses_label": "Utilisations maximales", + "max_uses_placeholder": "Laissez vide pour illimité", + "expiration_label": "Expiration", + "expiration_options": { + "never": "Jamais", + "24_hours": "24 Heures", + "7_days": "7 Jours", + "30_days": "30 Jours" + }, + "cancel_button": "Annuler", + "create_button": "Créer" + }, + "saving_button": "Enregistrement...", + "save_settings_button": "Enregistrer les paramètres" + }, + "invites_page": { + "title": "Codes d'invitation", + "description": "Gérez les codes d'invitation pour l'inscription des nouveaux utilisateurs", + "create_invite_button": "Créer une invitation", + "loading": "Chargement des codes d'invitation...", + "table": { + "code_header": "Code", + "uses_header": "Utilisations", + "expires_header": "Expire", + "status_header": "État", + "never": "Jamais" + }, + "status": { + "active": "Actif", + "expired": "Expiré", + "used": "Utilisé", + "inactive": "Inactif" + }, + "no_invites": "Aucun code d'invitation pour l'instant", + "create_modal": { + "title": "Créer un code d'invitation", + "max_uses_label": "Utilisations maximales", + "expires_in_label": "Expire dans (jours)" + }, + "delete_modal": { + "title": "Désactiver le code d'invitation", + "confirm_text": "Désactiver", + "cancel_text": "Annuler", + "message": "Êtes-vous sûr de vouloir désactiver le code d'invitation {{code}} ? Cette action est irréversible." + }, + "toast": { + "created": "Code d'invitation créé", + "deactivated": "Code d'invitation désactivé", + "copied": "Code d'invitation copié dans le presse-papiers", + "fetch_error": "Échec de la récupération des codes d'invitation", + "create_error": "Échec de la création du code d'invitation", + "delete_error": "Échec de la désactivation du code d'invitation" + } + }, + "social_login": { + "continue_with": "Continuer avec {{provider}}", + "sign_up_with": "S'inscrire avec {{provider}}" + }, + "setup_page": { + "title": "Bienvenue sur Hemmelig", + "description": "Créez votre compte administrateur pour commencer", + "name_label": "Nom complet", + "name_placeholder": "Entrez votre nom complet", + "username_label": "Nom d'utilisateur", + "username_placeholder": "Choisissez un nom d'utilisateur", + "email_label": "Adresse e-mail", + "email_placeholder": "Entrez votre e-mail", + "password_label": "Mot de passe", + "password_placeholder": "Créez un mot de passe (min. 8 caractères)", + "confirm_password_label": "Confirmer le mot de passe", + "confirm_password_placeholder": "Confirmez votre mot de passe", + "create_admin": "Créer le compte administrateur", + "creating": "Création du compte...", + "success": "Compte administrateur créé avec succès ! Veuillez vous connecter.", + "error": "Échec de la création du compte administrateur", + "passwords_mismatch": "Les mots de passe ne correspondent pas", + "password_too_short": "Le mot de passe doit contenir au moins 8 caractères", + "note": "Cette configuration ne peut être effectuée qu'une seule fois. Le compte administrateur aura un accès complet pour gérer cette instance." + }, + "theme_toggle": { + "switch_to_light": "Passer en mode clair", + "switch_to_dark": "Passer en mode sombre" + }, + "webhook_settings": { + "title": "Notifications webhook", + "description": "Notifier les services externes lorsque des secrets sont consultés ou détruits", + "enable_webhooks_title": "Activer les webhooks", + "enable_webhooks_description": "Envoyer des requêtes HTTP POST à votre URL webhook lors d'événements", + "webhook_url_label": "URL du webhook", + "webhook_url_placeholder": "https://exemple.com/webhook", + "webhook_url_hint": "L'URL où les charges webhook seront envoyées", + "webhook_secret_label": "Secret du webhook", + "webhook_secret_placeholder": "Entrez un secret pour la signature HMAC", + "webhook_secret_hint": "Utilisé pour signer les charges webhook avec HMAC-SHA256. La signature est envoyée dans l'en-tête X-Hemmelig-Signature.", + "events_title": "Événements webhook", + "on_view_title": "Secret consulté", + "on_view_description": "Envoyer un webhook lorsqu'un secret est consulté", + "on_burn_title": "Secret détruit", + "on_burn_description": "Envoyer un webhook lorsqu'un secret est détruit ou supprimé" + }, + "metrics_settings": { + "title": "Métriques Prometheus", + "description": "Exposer les métriques pour la surveillance avec Prometheus", + "enable_metrics_title": "Activer les métriques Prometheus", + "enable_metrics_description": "Exposer un point de terminaison /api/metrics pour le scraping Prometheus", + "metrics_secret_label": "Secret des métriques", + "metrics_secret_placeholder": "Entrez un secret pour l'authentification", + "metrics_secret_hint": "Utilisé comme token Bearer pour authentifier les requêtes au point de terminaison des métriques. Laissez vide pour aucune authentification (non recommandé).", + "endpoint_info_title": "Informations sur le point de terminaison", + "endpoint_info_description": "Une fois activées, les métriques seront disponibles à :", + "endpoint_auth_hint": "Incluez le secret comme token Bearer dans l'en-tête Authorization lors de la récupération des métriques." + }, + "verify_2fa_page": { + "back_to_login": "Retour à la connexion", + "title": "Authentification à deux facteurs", + "description": "Entrez le code à 6 chiffres de votre application d'authentification", + "enter_code_hint": "Entrez le code de votre application d'authentification", + "verifying": "Vérification...", + "verify_button": "Vérifier", + "invalid_code": "Code de vérification invalide. Veuillez réessayer.", + "unexpected_error": "Une erreur inattendue s'est produite. Veuillez réessayer." + }, + "common": { + "error": "Erreur", + "cancel": "Annuler", + "confirm": "Confirmer", + "ok": "OK", + "delete": "Supprimer", + "deleting": "Suppression...", + "loading": "Chargement..." + }, + "pagination": { + "showing": "Affichage de {{start}} à {{end}} sur {{total}} résultats", + "previous_page": "Page précédente", + "next_page": "Page suivante" + }, + "not_found_page": { + "title": "Page introuvable", + "message": "Cette page s'est volatilisée, tout comme nos secrets.", + "hint": "La page que vous recherchez n'existe pas ou a été déplacée.", + "go_home_button": "Retour à l'accueil", + "create_secret_button": "Créer un secret" + }, + "error_boundary": { + "title": "Une erreur s'est produite", + "message": "Une erreur inattendue s'est produite lors du traitement de votre demande.", + "hint": "Ne vous inquiétez pas, vos secrets sont toujours en sécurité. Essayez de rafraîchir la page.", + "error_details": "Détails de l'erreur :", + "unknown_error": "Une erreur inconnue s'est produite", + "try_again_button": "Réessayer", + "go_home_button": "Retour à l'accueil" + }, + "secret_requests_page": { + "title": "Demandes de secrets", + "description": "Demandez des secrets à d'autres via des liens sécurisés", + "create_request_button": "Créer une demande", + "no_requests": "Pas encore de demandes de secrets. Créez-en une pour commencer.", + "table": { + "title_header": "Titre", + "status_header": "Statut", + "secret_expiry_header": "Expiration du secret", + "link_expires_header": "Expiration du lien", + "copy_link_tooltip": "Copier le lien créateur", + "view_secret_tooltip": "Voir le secret", + "cancel_tooltip": "Annuler la demande" + }, + "status": { + "pending": "En attente", + "fulfilled": "Complétée", + "expired": "Expirée", + "cancelled": "Annulée" + }, + "time": { + "days": "{{count}} jour", + "days_plural": "{{count}} jours", + "hours": "{{count}} heure", + "hours_plural": "{{count}} heures", + "minutes": "{{count}} minute", + "minutes_plural": "{{count}} minutes" + }, + "link_modal": { + "title": "Lien créateur", + "description": "Envoyez ce lien à la personne qui doit fournir le secret. Elle pourra entrer et chiffrer le secret en utilisant ce lien.", + "copy_button": "Copier le lien", + "close_button": "Fermer", + "warning": "Ce lien ne peut être utilisé qu'une seule fois. Une fois un secret soumis, le lien ne fonctionnera plus." + }, + "cancel_modal": { + "title": "Annuler la demande", + "message": "Êtes-vous sûr de vouloir annuler la demande \"{{title}}\" ? Cette action ne peut pas être annulée.", + "confirm_text": "Annuler la demande", + "cancel_text": "Garder la demande" + }, + "toast": { + "copied": "Lien copié dans le presse-papiers", + "cancelled": "Demande annulée", + "fetch_error": "Échec de la récupération des détails de la demande", + "cancel_error": "Échec de l'annulation de la demande" + } + }, + "create_request_page": { + "title": "Créer une demande de secret", + "description": "Demandez un secret à quelqu'un en générant un lien sécurisé qu'il peut utiliser pour le soumettre", + "back_button": "Retour aux demandes de secrets", + "form": { + "title_label": "Titre de la demande", + "title_placeholder": "ex: Identifiants AWS pour le projet X", + "description_label": "Description (optionnel)", + "description_placeholder": "Fournissez un contexte supplémentaire sur ce dont vous avez besoin...", + "link_validity_label": "Validité du lien", + "link_validity_hint": "Durée pendant laquelle le lien créateur restera actif", + "secret_settings_title": "Paramètres du secret", + "secret_settings_description": "Ces paramètres s'appliqueront au secret une fois créé", + "secret_expiration_label": "Expiration du secret", + "max_views_label": "Vues maximum", + "password_label": "Protection par mot de passe (optionnel)", + "password_placeholder": "Entrez un mot de passe (min 5 caractères)", + "password_hint": "Les destinataires auront besoin de ce mot de passe pour voir le secret", + "ip_restriction_label": "Restriction IP (optionnel)", + "ip_restriction_placeholder": "192.168.1.0/24 ou 203.0.113.5", + "prevent_burn_label": "Empêcher la destruction auto (garder le secret même après le max de vues)", + "webhook_title": "Notification webhook (optionnel)", + "webhook_description": "Recevez une notification lorsque le secret est soumis", + "webhook_url_label": "URL du webhook", + "webhook_url_placeholder": "https://votre-serveur.com/webhook", + "webhook_url_hint": "HTTPS recommandé. Une notification sera envoyée lorsque le secret sera créé.", + "creating_button": "Création...", + "create_button": "Créer la demande" + }, + "validity": { + "30_days": "30 jours", + "14_days": "14 jours", + "7_days": "7 jours", + "3_days": "3 jours", + "1_day": "1 jour", + "12_hours": "12 heures", + "1_hour": "1 heure" + }, + "success": { + "title": "Demande créée !", + "description": "Partagez le lien créateur avec la personne qui doit fournir le secret", + "creator_link_label": "Lien créateur", + "webhook_secret_label": "Secret webhook", + "webhook_secret_warning": "Sauvegardez ce secret maintenant ! Il ne sera plus affiché. Utilisez-le pour vérifier les signatures webhook.", + "expires_at": "Le lien expire : {{date}}", + "create_another_button": "Créer une autre demande", + "view_all_button": "Voir toutes les demandes" + }, + "toast": { + "created": "Demande de secret créée avec succès", + "create_error": "Échec de la création de la demande de secret", + "copied": "Copié dans le presse-papiers" + } + }, + "request_secret_page": { + "loading": "Chargement de la demande...", + "error": { + "title": "Demande indisponible", + "invalid_link": "Ce lien est invalide ou a été modifié.", + "not_found": "Cette demande n'a pas été trouvée ou le lien est invalide.", + "already_fulfilled": "Cette demande a déjà été complétée ou a expiré.", + "generic": "Une erreur s'est produite lors du chargement de la demande.", + "go_home_button": "Aller à l'accueil" + }, + "form": { + "title": "Soumettre un secret", + "description": "Quelqu'un vous a demandé de partager un secret avec lui de manière sécurisée", + "password_protected_note": "Ce secret sera protégé par mot de passe", + "encryption_note": "Votre secret sera chiffré dans votre navigateur avant d'être envoyé. La clé de déchiffrement ne sera incluse que dans l'URL finale que vous partagerez.", + "submitting_button": "Chiffrement et soumission...", + "submit_button": "Soumettre le secret" + }, + "success": { + "title": "Secret créé !", + "description": "Votre secret a été chiffré et stocké de manière sécurisée", + "decryption_key_label": "Clé de déchiffrement", + "warning": "Important : Copiez cette clé de déchiffrement maintenant et envoyez-la au demandeur. C'est la seule fois que vous la verrez !", + "manual_send_note": "Vous devez envoyer manuellement cette clé de déchiffrement à la personne qui a demandé le secret. Elle a déjà l'URL du secret dans son tableau de bord.", + "create_own_button": "Créer votre propre secret" + }, + "toast": { + "created": "Secret soumis avec succès", + "create_error": "Échec de la soumission du secret", + "copied": "Copié dans le presse-papiers" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/it/it.json b/src/i18n/locales/it/it.json new file mode 100644 index 0000000..cce559d --- /dev/null +++ b/src/i18n/locales/it/it.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Modelli", + "description": "Inizia rapidamente con un modello", + "templates": { + "credentials": "Credenziali di accesso", + "api_key": "Chiave API", + "database": "Database", + "server": "Accesso server", + "credit_card": "Carta di pagamento", + "email": "Account e-mail" + } + }, + "editor": { + "tooltips": { + "copy_text": "Copia come testo semplice", + "copy_html": "Copia come HTML", + "copy_base64": "Copia come Base64", + "bold": "Grassetto", + "italic": "Corsivo", + "strikethrough": "Barrato", + "inline_code": "Codice inline", + "link": "Link", + "remove_link": "Rimuovi link", + "insert_password": "Inserisci password", + "paragraph": "Paragrafo", + "heading1": "Titolo 1", + "heading2": "Titolo 2", + "heading3": "Titolo 3", + "bullet_list": "Elenco puntato", + "numbered_list": "Elenco numerato", + "blockquote": "Citazione", + "code_block": "Blocco di codice", + "undo": "Annulla", + "redo": "Ripristina" + }, + "copy_success": { + "html": "HTML copiato!", + "text": "Testo copiato!", + "base64": "Base64 copiato!" + }, + "link_modal": { + "title": "Aggiungi link", + "url_label": "URL", + "url_placeholder": "Inserisci URL", + "cancel": "Annulla", + "update": "Aggiorna", + "insert": "Inserisci" + }, + "password_modal": { + "title": "Genera password", + "length_label": "Lunghezza password", + "options_label": "Opzioni", + "include_numbers": "Numeri", + "include_symbols": "Simboli", + "include_uppercase": "Maiuscole", + "include_lowercase": "Minuscole", + "generated_password": "Password generata", + "refresh": "Aggiorna", + "cancel": "Annulla", + "insert": "Inserisci", + "copied_and_added": "Password aggiunta e copiata negli appunti", + "added": "Password aggiunta" + }, + "formatting_tools": "Strumenti di formattazione", + "character_count": "caratteri" + }, + "create_button": { + "creating_secret": "Creazione segreto...", + "create": "Crea" + }, + "file_upload": { + "sign_in_to_upload": "Accedi per caricare file", + "sign_in": "Accedi", + "drop_files_here": "Rilascia i file qui", + "drag_and_drop": "Trascina e rilascia un file, o clicca per selezionare", + "uploading": "Caricamento...", + "upload_file": "Carica file", + "file_too_large": "Il file \"{{fileName}}\" ({{fileSize}} MB) supera la dimensione massima di {{maxSize}} MB", + "max_size_exceeded": "La dimensione totale dei file supera il massimo di {{maxSize}} MB" + }, + "footer": { + "tagline": "Hemmelig, [heˈm(ɛ)li], significa 'segreto' in norvegese", + "privacy": "Privacy", + "terms": "Termini", + "api": "API", + "managed_hosting": "Hosting gestito", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Home", + "sign_in": "Accedi", + "sign_up": "Registrati", + "dashboard": "Dashboard", + "hero_text_part1": "Condividi segreti in modo sicuro con messaggi crittografati che", + "hero_text_part2": " si autodistruggono", + "hero_text_part3": " automaticamente dopo la lettura." + }, + "dashboard_layout": { + "secrets": "Segreti", + "secret_requests": "Richieste di segreti", + "account": "Account", + "analytics": "Analitiche", + "users": "Utenti", + "invites": "Inviti", + "instance": "Istanza", + "sign_out": "Esci", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Segreto creato!", + "secret_created_description": "Il tuo segreto è ora disponibile al seguente URL. Conserva la tua chiave di decrittazione al sicuro, poiché non può essere recuperata.", + "secret_url_label": "URL del segreto", + "decryption_key_label": "Chiave di decrittazione", + "password_label": "Password", + "create_new_secret_button": "Crea nuovo segreto", + "copy_url_button": "Copia URL", + "burn_secret_button": "Distruggi segreto", + "max_secrets_per_user_info": "Puoi creare fino a {{count}} segreti.", + "failed_to_burn": "Impossibile distruggere il segreto. Riprova." + }, + "security_settings": { + "security_title": "Sicurezza", + "security_description": "Configura le impostazioni di sicurezza per il tuo segreto", + "remember_settings": "Ricorda", + "private_title": "Privato", + "private_description": "I segreti privati sono crittografati e possono essere visualizzati solo con la chiave di decrittazione e/o la password.", + "expiration_title": "Scadenza", + "expiration_burn_after_time_description": "Imposta quando il segreto deve essere distrutto", + "expiration_default_description": "Imposta per quanto tempo il segreto deve essere disponibile", + "max_views_title": "Visualizzazioni massime", + "burn_after_time_mode_title": "Modalità distruzione programmata", + "burn_after_time_mode_description": "Il segreto verrà distrutto dopo la scadenza del tempo, indipendentemente da quante volte viene visualizzato.", + "password_protection_title": "Protezione con password", + "password_protection_description": "Aggiungi un ulteriore livello di sicurezza con una password", + "enter_password_label": "Inserisci password", + "password_placeholder": "Inserisci una password sicura...", + "password_hint": "Minimo 5 caratteri. I destinatari avranno bisogno di questa password per vedere il segreto", + "password_error": "La password deve contenere almeno 5 caratteri", + "ip_restriction_title": "Restrizione per IP o CIDR", + "ip_restriction_description": "L'input CIDR permetterà agli utenti di specificare gli intervalli di indirizzi IP che possono accedere al segreto.", + "ip_address_cidr_label": "Indirizzo IP o intervallo CIDR", + "ip_address_cidr_placeholder": "192.168.1.0/24 o 203.0.113.5", + "ip_address_cidr_hint": "Solo le richieste da questi indirizzi IP potranno accedere al segreto", + "burn_after_time_title": "Distruggi dopo la scadenza", + "burn_after_time_description": "Distruggi il segreto solo dopo la scadenza del tempo" + }, + "title_field": { + "placeholder": "Titolo", + "hint": "Dai al tuo segreto un titolo memorabile (opzionale)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "visualizzazioni" + }, + "account_page": { + "title": "Impostazioni account", + "description": "Gestisci le preferenze del tuo account e la sicurezza", + "tabs": { + "profile": "Profilo", + "security": "Sicurezza", + "developer": "Sviluppatore", + "danger_zone": "Zona pericolosa" + }, + "profile_info": { + "title": "Informazioni profilo", + "description": "Aggiorna le tue informazioni personali", + "first_name_label": "Nome", + "last_name_label": "Cognome", + "username_label": "Nome utente", + "email_label": "Indirizzo e-mail", + "saving_button": "Salvataggio...", + "save_changes_button": "Salva modifiche" + }, + "profile_settings": { + "username_taken": "Nome utente già in uso" + }, + "security_settings": { + "title": "Impostazioni di sicurezza", + "description": "Gestisci la tua password e le preferenze di sicurezza", + "change_password_title": "Cambia password", + "current_password_label": "Password attuale", + "current_password_placeholder": "Inserisci la password attuale", + "new_password_label": "Nuova password", + "new_password_placeholder": "Inserisci la nuova password", + "confirm_new_password_label": "Conferma nuova password", + "confirm_new_password_placeholder": "Conferma la nuova password", + "password_mismatch_alert": "Le nuove password non corrispondono", + "changing_password_button": "Modifica in corso...", + "change_password_button": "Cambia password", + "password_change_success": "Password cambiata con successo!", + "password_change_error": "Impossibile cambiare la password. Riprova." + }, + "two_factor": { + "title": "Autenticazione a due fattori", + "description": "Aggiungi un ulteriore livello di sicurezza al tuo account", + "enabled": "Attivata", + "disabled": "Non attivata", + "setup_button": "Configura 2FA", + "disable_button": "Disattiva 2FA", + "enter_password_to_enable": "Inserisci la tua password per attivare l'autenticazione a due fattori.", + "continue": "Continua", + "scan_qr_code": "Scansiona questo codice QR con la tua app di autenticazione (Google Authenticator, Authy, ecc.).", + "manual_entry_hint": "Oppure inserisci manualmente questo codice nella tua app di autenticazione:", + "enter_verification_code": "Inserisci il codice a 6 cifre dalla tua app di autenticazione per verificare la configurazione.", + "verification_code": "Codice di verifica", + "verify_and_enable": "Verifica e attiva", + "back": "Indietro", + "disable_title": "Disattiva autenticazione a due fattori", + "disable_warning": "Disattivare 2FA renderà il tuo account meno sicuro. Dovrai inserire la tua password per confermare.", + "invalid_password": "Password non valida. Riprova.", + "invalid_code": "Codice di verifica non valido. Riprova.", + "enable_error": "Impossibile attivare 2FA. Riprova.", + "verify_error": "Impossibile verificare il codice 2FA. Riprova.", + "disable_error": "Impossibile disattivare 2FA. Riprova.", + "backup_codes_title": "Codici di backup", + "backup_codes_description": "Salva questi codici di backup in un luogo sicuro. Puoi usarli per accedere al tuo account se perdi l'accesso alla tua app di autenticazione.", + "backup_codes_warning": "Ogni codice può essere usato solo una volta. Conservali in modo sicuro!", + "backup_codes_saved": "Ho salvato i miei codici di backup" + }, + "danger_zone": { + "title": "Zona pericolosa", + "description": "Azioni irreversibili e distruttive", + "delete_account_title": "Elimina account", + "delete_account_description": "Una volta eliminato il tuo account, non si può tornare indietro. Questo eliminerà permanentemente il tuo account, tutti i tuoi segreti e tutti i dati associati. Questa azione non può essere annullata.", + "delete_account_bullet1": "Tutti i tuoi segreti verranno eliminati permanentemente", + "delete_account_bullet2": "I dati del tuo account verranno rimossi dai nostri server", + "delete_account_bullet3": "Tutti i link ai segreti condivisi diventeranno non validi", + "delete_account_bullet4": "Questa azione non può essere annullata", + "delete_account_confirm": "Sei sicuro di voler eliminare il tuo account? Questa azione non può essere annullata.", + "delete_account_button": "Elimina account", + "deleting_account_button": "Eliminazione account..." + }, + "developer": { + "title": "Chiavi API", + "description": "Gestisci le chiavi API per l'accesso programmatico", + "create_key": "Crea chiave", + "create_key_title": "Crea chiave API", + "key_name": "Nome chiave", + "key_name_placeholder": "es. La mia integrazione", + "expiration": "Scadenza", + "never_expires": "Non scade mai", + "expires_30_days": "30 giorni", + "expires_90_days": "90 giorni", + "expires_1_year": "1 anno", + "create_button": "Crea", + "name_required": "Il nome della chiave è obbligatorio", + "create_error": "Impossibile creare la chiave API", + "key_created": "Chiave API creata!", + "key_warning": "Copia questa chiave ora. Non potrai vederla di nuovo.", + "dismiss": "Ho copiato la chiave", + "no_keys": "Nessuna chiave API ancora. Creane una per iniziare.", + "created": "Creata", + "last_used": "Ultimo utilizzo", + "expires": "Scade", + "docs_hint": "Scopri come usare le chiavi API nella", + "api_docs": "Documentazione API" + } + }, + "analytics_page": { + "title": "Analitiche", + "description": "Monitora l'attività e le statistiche dei tuoi segreti condivisi", + "time_range": { + "last_7_days": "Ultimi 7 giorni", + "last_14_days": "Ultimi 14 giorni", + "last_30_days": "Ultimi 30 giorni" + }, + "total_secrets": "Segreti totali", + "from_last_period": "+{{percentage}}% dal periodo precedente", + "total_views": "Visualizzazioni totali", + "avg_views_per_secret": "Media visualizzazioni/segreto", + "active_secrets": "Segreti attivi", + "daily_activity": { + "title": "Attività giornaliera", + "description": "Segreti creati e visualizzazioni nel tempo", + "secrets": "Segreti", + "views": "Visualizzazioni", + "secrets_created": "Segreti Creati", + "secret_views": "Visualizzazioni Segreti", + "date": "Data", + "trend": "Variazione", + "vs_previous": "vs giorno precedente", + "no_data": "Nessun dato di attività disponibile ancora." + }, + "locale": "it-IT", + "top_countries": { + "title": "Principali paesi", + "description": "Dove vengono visualizzati i tuoi segreti", + "views": "visualizzazioni" + }, + "secret_types": { + "title": "Tipi di segreti", + "description": "Distribuzione per livello di protezione", + "password_protected": "Protetto da password", + "ip_restricted": "Restrizione IP", + "burn_after_time": "Distruzione programmata" + }, + "expiration_stats": { + "title": "Statistiche di scadenza", + "description": "Quanto durano tipicamente i segreti", + "one_hour": "1 Ora", + "one_day": "1 Giorno", + "one_week_plus": "1 Settimana+" + }, + "visitor_analytics": { + "title": "Analisi dei visitatori", + "description": "Visualizzazioni di pagina e visitatori unici", + "unique": "Unici", + "views": "Visite", + "date": "Data", + "trend": "Variazione", + "vs_previous": "vs giorno precedente", + "no_data": "Nessun dato sui visitatori disponibile ancora." + }, + "secret_requests": { + "total": "Richieste di segreti", + "fulfilled": "Richieste soddisfatte" + }, + "loading": "Caricamento analisi...", + "no_permission": "Non hai il permesso di visualizzare le analitiche.", + "failed_to_fetch": "Impossibile recuperare i dati analitici." + }, + "instance_page": { + "title": "Impostazioni istanza", + "description": "Configura la tua istanza Hemmelig", + "managed_mode": { + "title": "Modalità gestita", + "description": "Questa istanza è gestita tramite variabili d'ambiente. Le impostazioni sono di sola lettura." + }, + "tabs": { + "general": "Generale", + "security": "Sicurezza", + "organization": "Organizzazione", + "webhook": "Webhook", + "metrics": "Metriche" + }, + "system_status": { + "title": "Stato del sistema", + "description": "Salute dell'istanza e metriche di prestazione", + "version": "Versione", + "uptime": "Tempo di attività", + "memory": "Memoria", + "cpu_usage": "Utilizzo CPU" + }, + "general_settings": { + "title": "Impostazioni generali", + "description": "Configurazione base dell'istanza", + "instance_name_label": "Nome istanza", + "logo_label": "Logo istanza", + "logo_upload": "Carica logo", + "logo_remove": "Rimuovi logo", + "logo_hint": "PNG, JPEG, GIF, SVG o WebP. Max 512KB.", + "logo_alt": "Logo istanza", + "logo_invalid_type": "Tipo di file non valido. Carica un'immagine PNG, JPEG, GIF, SVG o WebP.", + "logo_too_large": "File troppo grande. Dimensione massima: 512KB.", + "default_expiration_label": "Scadenza predefinita", + "max_secrets_per_user_label": "Max segreti per utente", + "max_secret_size_label": "Dimensione max segreto (MB)", + "instance_description_label": "Descrizione istanza", + "important_message_label": "Messaggio importante", + "important_message_placeholder": "Inserisci un messaggio importante da mostrare a tutti gli utenti...", + "important_message_hint": "Questo messaggio verrà visualizzato come banner di avviso nella pagina principale. Supporta la formattazione markdown. Lascia vuoto per nascondere.", + "allow_registration_title": "Consenti registrazione", + "allow_registration_description": "Permetti ai nuovi utenti di registrarsi", + "email_verification_title": "Verifica e-mail", + "email_verification_description": "Richiedi la verifica dell'e-mail" + }, + "saving_button": "Salvataggio...", + "save_settings_button": "Salva impostazioni", + "security_settings": { + "title": "Impostazioni di sicurezza", + "description": "Configura sicurezza e controlli di accesso", + "rate_limiting_title": "Limitazione richieste", + "rate_limiting_description": "Abilita la limitazione del tasso di richieste", + "max_password_attempts_label": "Max tentativi password", + "session_timeout_label": "Timeout sessione (ore)", + "allow_file_uploads_title": "Consenti caricamento file", + "allow_file_uploads_description": "Consenti agli utenti di allegare file ai segreti" + }, + "email_settings": { + "title": "Impostazioni e-mail", + "description": "Configura SMTP e notifiche e-mail", + "smtp_host_label": "Host SMTP", + "smtp_port_label": "Porta SMTP", + "username_label": "Nome utente", + "password_label": "Password" + }, + "database_info": { + "title": "Informazioni database", + "description": "Stato e statistiche del database", + "stats_title": "Statistiche database", + "total_secrets": "Segreti totali:", + "total_users": "Utenti totali:", + "disk_usage": "Utilizzo disco:", + "connection_status_title": "Stato connessione", + "connected": "Connesso", + "connected_description": "Il database è sano e risponde normalmente" + }, + "system_info": { + "title": "Informazioni di sistema", + "description": "Dettagli server e manutenzione", + "system_info_title": "Info sistema", + "version": "Versione:", + "uptime": "Tempo di attività:", + "status": "Stato:", + "resource_usage_title": "Utilizzo risorse", + "memory": "Memoria:", + "cpu": "CPU:", + "disk": "Disco:" + }, + "maintenance_actions": { + "title": "Azioni di manutenzione", + "description": "Queste azioni possono influire sulla disponibilità del sistema. Usare con cautela.", + "restart_service_button": "Riavvia servizio", + "clear_cache_button": "Svuota cache", + "export_logs_button": "Esporta log" + } + }, + "secrets_page": { + "title": "I tuoi segreti", + "description": "Gestisci e monitora i tuoi segreti condivisi", + "create_secret_button": "Crea segreto", + "search_placeholder": "Cerca segreti...", + "filter": { + "all_secrets": "Tutti i segreti", + "active": "Attivo", + "expired": "Scaduto" + }, + "total_secrets": "Segreti totali", + "active_secrets": "Attivi", + "expired_secrets": "Scaduti", + "no_secrets_found_title": "Nessun segreto trovato", + "no_secrets_found_description_filter": "Prova a modificare i criteri di ricerca o filtro.", + "no_secrets_found_description_empty": "Crea il tuo primo segreto per iniziare.", + "password_protected": "Password", + "files": "file", + "table": { + "secret_header": "Segreto", + "created_header": "Creato", + "status_header": "Stato", + "views_header": "Visualizzazioni", + "actions_header": "Azioni", + "untitled_secret": "Segreto senza titolo", + "expired_status": "Scaduto", + "active_status": "Attivo", + "never_expires": "Non scade mai", + "expired_time": "Scaduto", + "views_left": "visualizzazioni rimanenti", + "copy_url_tooltip": "Copia URL", + "open_secret_tooltip": "Apri segreto", + "delete_secret_tooltip": "Elimina segreto", + "delete_confirmation_title": "Sei sicuro?", + "delete_confirmation_text": "Questa azione non può essere annullata. Il segreto verrà eliminato permanentemente.", + "delete_confirm_button": "Sì, elimina", + "delete_cancel_button": "Annulla" + } + }, + "users_page": { + "title": "Gestione utenti", + "description": "Gestisci utenti e i loro permessi", + "add_user_button": "Aggiungi utente", + "search_placeholder": "Cerca utenti...", + "filter": { + "all_roles": "Tutti i ruoli", + "admin": "Amministratore", + "user": "Utente", + "all_status": "Tutti gli stati", + "active": "Attivo", + "suspended": "Sospeso", + "pending": "In attesa" + }, + "total_users": "Utenti totali", + "active_users": "Attivi", + "admins": "Amministratori", + "pending_users": "In attesa", + "no_users_found_title": "Nessun utente trovato", + "no_users_found_description_filter": "Prova a modificare i criteri di ricerca o filtro.", + "no_users_found_description_empty": "Nessun utente è stato ancora aggiunto.", + "table": { + "user_header": "Utente", + "role_header": "Ruolo", + "status_header": "Stato", + "activity_header": "Attività", + "last_login_header": "Ultimo accesso", + "actions_header": "Azioni", + "created_at": "Creato il" + }, + "status": { + "active": "Attivo", + "banned": "Bannato" + }, + "delete_user_modal": { + "title": "Elimina utente", + "confirmation_message": "Sei sicuro di voler eliminare l'utente {{username}}? Questa azione non può essere annullata.", + "confirm_button": "Elimina", + "cancel_button": "Annulla" + }, + "edit_user_modal": { + "title": "Modifica utente: {{username}}", + "username_label": "Nome utente", + "email_label": "E-mail", + "role_label": "Ruolo", + "banned_label": "Bannato", + "save_button": "Salva", + "cancel_button": "Annulla" + }, + "add_user_modal": { + "title": "Aggiungi nuovo utente", + "name_label": "Nome", + "username_label": "Nome utente", + "email_label": "E-mail", + "password_label": "Password", + "role_label": "Ruolo", + "save_button": "Aggiungi utente", + "cancel_button": "Annulla" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Torna all'accesso", + "check_email_title": "Controlla la tua e-mail", + "check_email_description": "Abbiamo inviato un link per reimpostare la password a {{email}}", + "did_not_receive_email": "Non hai ricevuto l'e-mail? Controlla la cartella spam o riprova.", + "try_again_button": "Riprova", + "forgot_password_title": "Password dimenticata?", + "forgot_password_description": "Nessun problema, ti invieremo le istruzioni per reimpostarla", + "email_label": "E-mail", + "email_placeholder": "Inserisci la tua e-mail", + "email_hint": "Inserisci l'e-mail associata al tuo account", + "sending_button": "Invio...", + "reset_password_button": "Reimposta password", + "remember_password": "Ricordi la tua password?", + "sign_in_link": "Accedi", + "unexpected_error": "Si è verificato un errore imprevisto. Riprova." + }, + "login_page": { + "back_to_hemmelig": "Torna a Hemmelig", + "welcome_back": "Bentornato su Hemmelig", + "welcome_back_title": "Bentornato", + "welcome_back_description": "Accedi al tuo account Hemmelig", + "username_label": "Nome utente", + "username_placeholder": "Inserisci il tuo nome utente", + "password_label": "Password", + "password_placeholder": "Inserisci la tua password", + "forgot_password_link": "Password dimenticata?", + "signing_in_button": "Accesso in corso...", + "sign_in_button": "Accedi", + "or_continue_with": "Oppure continua con", + "continue_with_github": "Continua con GitHub", + "no_account_question": "Non hai un account?", + "sign_up_link": "Registrati", + "unexpected_error": "Si è verificato un errore imprevisto. Riprova." + }, + "register_page": { + "back_to_hemmelig": "Torna a Hemmelig", + "join_hemmelig": "Unisciti a Hemmelig per condividere segreti in sicurezza", + "email_password_disabled_message": "La registrazione con email e password è disabilitata. Utilizza una delle opzioni di login social qui sotto.", + "create_account_title": "Crea account", + "create_account_description": "Unisciti a Hemmelig per condividere segreti in sicurezza", + "username_label": "Nome utente", + "username_placeholder": "Scegli un nome utente", + "email_label": "E-mail", + "email_placeholder": "Inserisci la tua e-mail", + "password_label": "Password", + "password_placeholder": "Crea una password", + "password_strength_label": "Forza della password", + "password_strength_levels": { + "very_weak": "Molto debole", + "weak": "Debole", + "fair": "Discreta", + "good": "Buona", + "strong": "Forte" + }, + "confirm_password_label": "Conferma password", + "confirm_password_placeholder": "Conferma la tua password", + "passwords_match": "Le password corrispondono", + "passwords_do_not_match": "Le password non corrispondono", + "password_mismatch_alert": "Le password non corrispondono", + "creating_account_button": "Creazione account...", + "create_account_button": "Crea account", + "or_continue_with": "Oppure continua con", + "continue_with_github": "Continua con GitHub", + "already_have_account_question": "Hai già un account?", + "sign_in_link": "Accedi", + "invite_code_label": "Codice invito", + "invite_code_placeholder": "Inserisci il tuo codice invito", + "invite_code_required": "Il codice invito è obbligatorio", + "invalid_invite_code": "Codice invito non valido", + "failed_to_validate_invite": "Impossibile validare il codice invito", + "unexpected_error": "Si è verificato un errore imprevisto. Riprova.", + "email_domain_not_allowed": "Dominio e-mail non consentito", + "account_already_exists": "Esiste già un account con questa e-mail. Effettua l'accesso." + }, + "secret_form": { + "failed_to_create_secret": "Impossibile creare il segreto: {{errorMessage}}", + "failed_to_upload_file": "Impossibile caricare il file: {{fileName}}" + }, + "secret_page": { + "password_label": "Password", + "password_placeholder": "Inserisci la password per vedere il segreto", + "decryption_key_label": "Chiave di decrittazione", + "decryption_key_placeholder": "Inserisci la chiave di decrittazione", + "view_secret_button": "Visualizza segreto", + "views_remaining_tooltip": "Visualizzazioni rimanenti: {{count}}", + "loading_message": "Decrittazione del segreto...", + "files_title": "File allegati", + "secret_waiting_title": "Qualcuno ha condiviso un segreto con te", + "secret_waiting_description": "Questo segreto è crittografato e può essere visualizzato solo cliccando il pulsante qui sotto.", + "one_view_remaining": "Questo segreto può essere visualizzato solo 1 altra volta", + "views_remaining": "Questo segreto può essere visualizzato altre {{count}} volte", + "view_warning": "Una volta visualizzato, questa azione non può essere annullata", + "secret_revealed": "Segreto", + "copy_secret": "Copia negli appunti", + "download": "Scarica", + "create_your_own": "Crea il tuo segreto", + "encrypted_secret": "Segreto crittografato", + "unlock_secret": "Sblocca segreto", + "delete_secret": "Elimina segreto", + "delete_modal_title": "Elimina segreto", + "delete_modal_message": "Sei sicuro di voler eliminare questo segreto? Questa azione non può essere annullata.", + "decryption_failed": "Impossibile decifrare il segreto. Verifica la tua password o la chiave di decrittazione.", + "fetch_error": "Si è verificato un errore durante il recupero del segreto. Riprova." + }, + "expiration": { + "28_days": "28 Giorni", + "14_days": "14 Giorni", + "7_days": "7 Giorni", + "3_days": "3 Giorni", + "1_day": "1 Giorno", + "12_hours": "12 Ore", + "4_hours": "4 Ore", + "1_hour": "1 Ora", + "30_minutes": "30 Minuti", + "5_minutes": "5 Minuti" + }, + "error_display": { + "clear_errors_button_title": "Cancella errori" + }, + "secret_not_found_page": { + "title": "Segreto non trovato", + "message": "Il segreto che stai cercando non esiste, è scaduto o è stato distrutto.", + "error_details": "Dettagli errore:", + "go_home_button": "Vai alla home" + }, + "organization_page": { + "title": "Impostazioni organizzazione", + "description": "Configura le impostazioni e i controlli di accesso dell'organizzazione", + "registration_settings": { + "title": "Impostazioni registrazione", + "description": "Controlla come gli utenti possono unirsi alla tua organizzazione", + "invite_only_title": "Solo su invito", + "invite_only_description": "Gli utenti possono registrarsi solo con un codice invito valido", + "require_registered_user_title": "Solo utenti registrati", + "require_registered_user_description": "Solo gli utenti registrati possono creare segreti", + "disable_email_password_signup_title": "Disabilita registrazione email/password", + "disable_email_password_signup_description": "Disabilita la registrazione con email e password (solo login social)", + "allowed_domains_title": "Domini e-mail consentiti", + "allowed_domains_description": "Consenti la registrazione solo da domini e-mail specifici (separati da virgola, es. azienda.com, org.net)", + "allowed_domains_placeholder": "azienda.com, org.net", + "allowed_domains_hint": "Lista di domini e-mail separati da virgola. Lascia vuoto per consentire tutti i domini." + }, + "invite_codes": { + "title": "Codici invito", + "description": "Crea e gestisci i codici invito per i nuovi utenti", + "create_invite_button": "Crea codice invito", + "code_header": "Codice", + "uses_header": "Utilizzi", + "expires_header": "Scade", + "actions_header": "Azioni", + "unlimited": "Illimitato", + "never": "Mai", + "expired": "Scaduto", + "no_invites": "Nessun codice invito ancora", + "no_invites_description": "Crea un codice invito per permettere ai nuovi utenti di registrarsi", + "copy_tooltip": "Copia codice", + "delete_tooltip": "Elimina codice" + }, + "create_invite_modal": { + "title": "Crea codice invito", + "max_uses_label": "Utilizzi massimi", + "max_uses_placeholder": "Lascia vuoto per illimitato", + "expiration_label": "Scadenza", + "expiration_options": { + "never": "Mai", + "24_hours": "24 Ore", + "7_days": "7 Giorni", + "30_days": "30 Giorni" + }, + "cancel_button": "Annulla", + "create_button": "Crea" + }, + "saving_button": "Salvataggio...", + "save_settings_button": "Salva impostazioni" + }, + "invites_page": { + "title": "Codici invito", + "description": "Gestisci i codici invito per la registrazione dei nuovi utenti", + "create_invite_button": "Crea invito", + "loading": "Caricamento codici invito...", + "table": { + "code_header": "Codice", + "uses_header": "Utilizzi", + "expires_header": "Scade", + "status_header": "Stato", + "never": "Mai" + }, + "status": { + "active": "Attivo", + "expired": "Scaduto", + "used": "Usato", + "inactive": "Inattivo" + }, + "no_invites": "Nessun codice invito ancora", + "create_modal": { + "title": "Crea codice invito", + "max_uses_label": "Utilizzi massimi", + "expires_in_label": "Scade tra (giorni)" + }, + "delete_modal": { + "title": "Disattiva codice invito", + "confirm_text": "Disattiva", + "cancel_text": "Annulla", + "message": "Sei sicuro di voler disattivare il codice invito {{code}}? Questa azione non può essere annullata." + }, + "toast": { + "created": "Codice invito creato", + "deactivated": "Codice invito disattivato", + "copied": "Codice invito copiato negli appunti", + "fetch_error": "Impossibile recuperare i codici invito", + "create_error": "Impossibile creare il codice invito", + "delete_error": "Impossibile disattivare il codice invito" + } + }, + "social_login": { + "continue_with": "Continua con {{provider}}", + "sign_up_with": "Registrati con {{provider}}" + }, + "setup_page": { + "title": "Benvenuto su Hemmelig", + "description": "Crea il tuo account amministratore per iniziare", + "name_label": "Nome completo", + "name_placeholder": "Inserisci il tuo nome completo", + "username_label": "Nome utente", + "username_placeholder": "Scegli un nome utente", + "email_label": "Indirizzo e-mail", + "email_placeholder": "Inserisci la tua e-mail", + "password_label": "Password", + "password_placeholder": "Crea una password (min. 8 caratteri)", + "confirm_password_label": "Conferma password", + "confirm_password_placeholder": "Conferma la tua password", + "create_admin": "Crea account amministratore", + "creating": "Creazione account...", + "success": "Account amministratore creato con successo! Effettua l'accesso.", + "error": "Impossibile creare l'account amministratore", + "passwords_mismatch": "Le password non corrispondono", + "password_too_short": "La password deve contenere almeno 8 caratteri", + "note": "Questa configurazione può essere completata solo una volta. L'account amministratore avrà pieno accesso per gestire questa istanza." + }, + "theme_toggle": { + "switch_to_light": "Passa alla modalità chiara", + "switch_to_dark": "Passa alla modalità scura" + }, + "webhook_settings": { + "title": "Notifiche webhook", + "description": "Notifica i servizi esterni quando i segreti vengono visualizzati o distrutti", + "enable_webhooks_title": "Abilita webhook", + "enable_webhooks_description": "Invia richieste HTTP POST al tuo URL webhook quando si verificano eventi", + "webhook_url_label": "URL webhook", + "webhook_url_placeholder": "https://esempio.com/webhook", + "webhook_url_hint": "L'URL dove verranno inviati i payload webhook", + "webhook_secret_label": "Segreto webhook", + "webhook_secret_placeholder": "Inserisci un segreto per la firma HMAC", + "webhook_secret_hint": "Usato per firmare i payload webhook con HMAC-SHA256. La firma viene inviata nell'header X-Hemmelig-Signature.", + "events_title": "Eventi webhook", + "on_view_title": "Segreto visualizzato", + "on_view_description": "Invia un webhook quando un segreto viene visualizzato", + "on_burn_title": "Segreto distrutto", + "on_burn_description": "Invia un webhook quando un segreto viene distrutto o eliminato" + }, + "metrics_settings": { + "title": "Metriche Prometheus", + "description": "Esponi metriche per il monitoraggio con Prometheus", + "enable_metrics_title": "Abilita metriche Prometheus", + "enable_metrics_description": "Esponi un endpoint /api/metrics per lo scraping di Prometheus", + "metrics_secret_label": "Segreto metriche", + "metrics_secret_placeholder": "Inserisci un segreto per l'autenticazione", + "metrics_secret_hint": "Usato come token Bearer per autenticare le richieste all'endpoint delle metriche. Lascia vuoto per nessuna autenticazione (non consigliato).", + "endpoint_info_title": "Informazioni endpoint", + "endpoint_info_description": "Una volta abilitate, le metriche saranno disponibili su:", + "endpoint_auth_hint": "Includi il segreto come token Bearer nell'header Authorization quando recuperi le metriche." + }, + "verify_2fa_page": { + "back_to_login": "Torna al login", + "title": "Autenticazione a due fattori", + "description": "Inserisci il codice a 6 cifre dalla tua app di autenticazione", + "enter_code_hint": "Inserisci il codice dalla tua app di autenticazione", + "verifying": "Verifica in corso...", + "verify_button": "Verifica", + "invalid_code": "Codice di verifica non valido. Riprova.", + "unexpected_error": "Si è verificato un errore imprevisto. Riprova." + }, + "common": { + "error": "Errore", + "cancel": "Annulla", + "confirm": "Conferma", + "ok": "OK", + "delete": "Elimina", + "deleting": "Eliminazione...", + "loading": "Caricamento..." + }, + "pagination": { + "showing": "Mostrando da {{start}} a {{end}} di {{total}} risultati", + "previous_page": "Pagina precedente", + "next_page": "Pagina successiva" + }, + "not_found_page": { + "title": "Pagina non trovata", + "message": "Questa pagina è svanita nel nulla, proprio come fanno i nostri segreti.", + "hint": "La pagina che stai cercando non esiste o è stata spostata.", + "go_home_button": "Torna alla home", + "create_secret_button": "Crea segreto" + }, + "error_boundary": { + "title": "Qualcosa è andato storto", + "message": "Si è verificato un errore imprevisto durante l'elaborazione della tua richiesta.", + "hint": "Non preoccuparti, i tuoi segreti sono ancora al sicuro. Prova ad aggiornare la pagina.", + "error_details": "Dettagli errore:", + "unknown_error": "Si è verificato un errore sconosciuto", + "try_again_button": "Riprova", + "go_home_button": "Torna alla home" + }, + "secret_requests_page": { + "title": "Richieste di segreti", + "description": "Richiedi segreti da altri tramite link sicuri", + "create_request_button": "Crea richiesta", + "no_requests": "Nessuna richiesta di segreti ancora. Creane una per iniziare.", + "table": { + "title_header": "Titolo", + "status_header": "Stato", + "secret_expiry_header": "Scadenza segreto", + "link_expires_header": "Scadenza link", + "copy_link_tooltip": "Copia link creatore", + "view_secret_tooltip": "Visualizza segreto", + "cancel_tooltip": "Annulla richiesta" + }, + "status": { + "pending": "In attesa", + "fulfilled": "Completata", + "expired": "Scaduta", + "cancelled": "Annullata" + }, + "time": { + "days": "{{count}} giorno", + "days_plural": "{{count}} giorni", + "hours": "{{count}} ora", + "hours_plural": "{{count}} ore", + "minutes": "{{count}} minuto", + "minutes_plural": "{{count}} minuti" + }, + "link_modal": { + "title": "Link creatore", + "description": "Invia questo link alla persona che deve fornire il segreto. Potrà inserire e crittografare il segreto usando questo link.", + "copy_button": "Copia link", + "close_button": "Chiudi", + "warning": "Questo link può essere usato solo una volta. Una volta inviato un segreto, il link non funzionerà più." + }, + "cancel_modal": { + "title": "Annulla richiesta", + "message": "Sei sicuro di voler annullare la richiesta \"{{title}}\"? Questa azione non può essere annullata.", + "confirm_text": "Annulla richiesta", + "cancel_text": "Mantieni richiesta" + }, + "toast": { + "copied": "Link copiato negli appunti", + "cancelled": "Richiesta annullata", + "fetch_error": "Impossibile recuperare i dettagli della richiesta", + "cancel_error": "Impossibile annullare la richiesta" + } + }, + "create_request_page": { + "title": "Crea richiesta di segreto", + "description": "Richiedi un segreto da qualcuno generando un link sicuro che può usare per inviarlo", + "back_button": "Torna alle richieste di segreti", + "form": { + "title_label": "Titolo richiesta", + "title_placeholder": "es. Credenziali AWS per Progetto X", + "description_label": "Descrizione (opzionale)", + "description_placeholder": "Fornisci contesto aggiuntivo su ciò di cui hai bisogno...", + "link_validity_label": "Validità link", + "link_validity_hint": "Per quanto tempo il link creatore rimarrà attivo", + "secret_settings_title": "Impostazioni segreto", + "secret_settings_description": "Queste impostazioni si applicheranno al segreto una volta creato", + "secret_expiration_label": "Scadenza segreto", + "max_views_label": "Visualizzazioni massime", + "password_label": "Protezione con password (opzionale)", + "password_placeholder": "Inserisci una password (min 5 caratteri)", + "password_hint": "I destinatari avranno bisogno di questa password per visualizzare il segreto", + "ip_restriction_label": "Restrizione IP (opzionale)", + "ip_restriction_placeholder": "192.168.1.0/24 o 203.0.113.5", + "prevent_burn_label": "Previeni auto-distruzione (mantieni il segreto dopo le visualizzazioni max)", + "webhook_title": "Notifica webhook (opzionale)", + "webhook_description": "Ricevi una notifica quando il segreto viene inviato", + "webhook_url_label": "URL webhook", + "webhook_url_placeholder": "https://tuo-server.com/webhook", + "webhook_url_hint": "HTTPS consigliato. Una notifica verrà inviata quando il segreto sarà creato.", + "creating_button": "Creazione...", + "create_button": "Crea richiesta" + }, + "validity": { + "30_days": "30 giorni", + "14_days": "14 giorni", + "7_days": "7 giorni", + "3_days": "3 giorni", + "1_day": "1 giorno", + "12_hours": "12 ore", + "1_hour": "1 ora" + }, + "success": { + "title": "Richiesta creata!", + "description": "Condividi il link creatore con la persona che deve fornire il segreto", + "creator_link_label": "Link creatore", + "webhook_secret_label": "Segreto webhook", + "webhook_secret_warning": "Salva questo segreto ora! Non verrà mostrato di nuovo. Usalo per verificare le firme webhook.", + "expires_at": "Il link scade: {{date}}", + "create_another_button": "Crea un'altra richiesta", + "view_all_button": "Visualizza tutte le richieste" + }, + "toast": { + "created": "Richiesta di segreto creata con successo", + "create_error": "Impossibile creare la richiesta di segreto", + "copied": "Copiato negli appunti" + } + }, + "request_secret_page": { + "loading": "Caricamento richiesta...", + "error": { + "title": "Richiesta non disponibile", + "invalid_link": "Questo link non è valido o è stato manomesso.", + "not_found": "Questa richiesta non è stata trovata o il link non è valido.", + "already_fulfilled": "Questa richiesta è già stata completata o è scaduta.", + "generic": "Si è verificato un errore durante il caricamento della richiesta.", + "go_home_button": "Vai alla home" + }, + "form": { + "title": "Invia un segreto", + "description": "Qualcuno ti ha chiesto di condividere un segreto in modo sicuro", + "password_protected_note": "Questo segreto sarà protetto da password", + "encryption_note": "Il tuo segreto verrà crittografato nel tuo browser prima di essere inviato. La chiave di decrittazione sarà inclusa solo nell'URL finale che condividerai.", + "submitting_button": "Crittografia e invio...", + "submit_button": "Invia segreto" + }, + "success": { + "title": "Segreto creato!", + "description": "Il tuo segreto è stato crittografato e memorizzato in modo sicuro", + "decryption_key_label": "Chiave di decrittazione", + "warning": "Importante: Copia questa chiave di decrittazione ora e inviala al richiedente. Questa è l'unica volta che la vedrai!", + "manual_send_note": "Devi inviare manualmente questa chiave di decrittazione alla persona che ha richiesto il segreto. Hanno già l'URL del segreto nella loro dashboard.", + "create_own_button": "Crea il tuo segreto" + }, + "toast": { + "created": "Segreto inviato con successo", + "create_error": "Impossibile inviare il segreto", + "copied": "Copiato negli appunti" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/nl/nl.json b/src/i18n/locales/nl/nl.json new file mode 100644 index 0000000..a1fdc25 --- /dev/null +++ b/src/i18n/locales/nl/nl.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Sjablonen", + "description": "Snel aan de slag met een sjabloon", + "templates": { + "credentials": "Inloggegevens", + "api_key": "API-sleutel", + "database": "Database", + "server": "Servertoegang", + "credit_card": "Betaalkaart", + "email": "E-mailaccount" + } + }, + "editor": { + "tooltips": { + "copy_text": "Kopiëren als platte tekst", + "copy_html": "Kopiëren als HTML", + "copy_base64": "Kopiëren als Base64", + "bold": "Vet", + "italic": "Cursief", + "strikethrough": "Doorhalen", + "inline_code": "Code", + "link": "Link", + "remove_link": "Link verwijderen", + "insert_password": "Wachtwoord invoegen", + "paragraph": "Alinea", + "heading1": "Kop 1", + "heading2": "Kop 2", + "heading3": "Kop 3", + "bullet_list": "Opsommingstekenslijst", + "numbered_list": "Genummerde lijst", + "blockquote": "Blokcitaat", + "code_block": "Codeblok", + "undo": "Ongedaan maken", + "redo": "Opnieuw uitvoeren" + }, + "copy_success": { + "html": "HTML gekopieerd naar klembord", + "text": "Tekst gekopieerd naar klembord", + "base64": "Base64 gekopieerd naar klembord" + }, + "link_modal": { + "title": "Link toevoegen", + "url_label": "URL", + "url_placeholder": "URL invoeren", + "cancel": "Annuleren", + "update": "Bijwerken", + "insert": "Invoegen" + }, + "password_modal": { + "title": "Wachtwoord invoegen", + "length_label": "Wachtwoordlengte", + "options_label": "Opties", + "include_numbers": "Cijfers", + "include_symbols": "Symbolen", + "include_uppercase": "Hoofdletters", + "include_lowercase": "Kleine letters", + "generated_password": "Gegenereerd wachtwoord", + "refresh": "Vernieuwen", + "cancel": "Annuleren", + "insert": "Invoegen", + "copied_and_added": "Wachtwoord toegevoegd en gekopieerd naar klembord", + "added": "Wachtwoord toegevoegd" + }, + "formatting_tools": "Stijlen", + "character_count": "tekens" + }, + "create_button": { + "creating_secret": "Geheim wordt aangemaakt...", + "create": "Geheim delen" + }, + "file_upload": { + "sign_in_to_upload": "Meld je aan om bestanden te uploaden", + "sign_in": "Aanmelden", + "drop_files_here": "Sleep je bestanden hierheen", + "drag_and_drop": "Sleep een bestand hierheen of klik om een bestand te kiezen", + "uploading": "Bezig met uploaden... ", + "upload_file": "Bestand uploaden", + "file_too_large": "Bestand \"{{fileName}}\" ({{fileSize}} MB) overschrijdt de maximale grootte van {{maxSize}} MB", + "max_size_exceeded": "De totale bestandsgrootte overschrijdt het maximum van {{maxSize}} MB" + }, + "footer": { + "tagline": "Hemmelig, [heˈm(ɛ)li], betekent 'geheim' in het Noors", + "privacy": "Privacy", + "terms": "Voorwaarden", + "api": "API", + "managed_hosting": "Managed Hosting", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Home", + "sign_in": "Aanmelden", + "sign_up": "Registreren", + "dashboard": "Dashboard", + "hero_text_part1": "Deel geheimen veilig via versleutelde berichten die zichzelf", + "hero_text_part2": " automatisch vernietigen", + "hero_text_part3": " nadat ze zijn gelezen." + }, + "dashboard_layout": { + "secrets": "Geheimen", + "secret_requests": "Geheimverzoeken", + "account": "Account", + "analytics": "Statistieken", + "users": "Gebruikers", + "invites": "Uitnodigingen", + "instance": "Server", + "sign_out": "Uitloggen", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Geheim is aangemaakt", + "secret_created_description": "Je geheim is nu beschikbaar via de volgende URL. Bewaar je decoderingssleutel zorgvuldig, want deze kan niet worden hersteld.", + "secret_url_label": "Geheime URL", + "decryption_key_label": "Decoderingssleutel", + "password_label": "Wachtwoord", + "create_new_secret_button": "Nieuw geheim delen", + "copy_url_button": "URL kopiëren", + "burn_secret_button": "Geheim vernietigen", + "max_secrets_per_user_info": "Je kunt maximaal {{count}} geheimen aanmaken.", + "failed_to_burn": "Het vernietigen van het geheim is mislukt. Probeer het opnieuw." + }, + "security_settings": { + "security_title": "Beveiliging", + "security_description": "Configureer beveiligingsinstellingen voor je geheim", + "remember_settings": "Onthouden", + "private_title": "Privé", + "private_description": "Privégeheimen zijn versleuteld en kunnen alleen worden bekeken met de decoderingssleutel en/of het wachtwoord.", + "expiration_title": "Vervaldatum", + "expiration_burn_after_time_description": "Stel in wanneer het geheim moet worden vernietigd", + "expiration_default_description": "Stel in hoe lang het geheim beschikbaar moet zijn", + "max_views_title": "Maximaal aantal weergaven", + "burn_after_time_mode_title": "Vernietigen na tijdmodus", + "burn_after_time_mode_description": "Het geheim wordt vernietigd nadat de tijd is verstreken, ongeacht hoe vaak het is bekeken.", + "password_protection_title": "Wachtwoordbeveiliging", + "password_protection_description": "Voeg een extra beveiligingslaag toe met een wachtwoord", + "enter_password_label": "Wachtwoord invoeren", + "password_placeholder": "Voer een veilig wachtwoord in...", + "password_hint": "Minimaal 5 tekens. Ontvangers hebben dit wachtwoord nodig om het geheim te bekijken", + "password_error": "Wachtwoord moet minimaal 5 tekens bevatten", + "ip_restriction_title": "Beperken op IP of CIDR", + "ip_restriction_description": "Met een CIDR kun je een bereik opgeven van IP-adressen die toegang hebben tot het geheim.", + "ip_address_cidr_label": "IP-adres of CIDR-bereik", + "ip_address_cidr_placeholder": "192.168.1.0/24 of 203.0.113.5", + "ip_address_cidr_hint": "Alleen verzoeken van deze IP-adressen hebben toegang tot het geheim", + "burn_after_time_title": "Vernietigen na het verlopen van de tijd", + "burn_after_time_description": "Vernietig het geheim pas nadat de tijd is verstreken" + }, + "title_field": { + "placeholder": "Titel", + "hint": "Geef je geheim een gemakkelijk te onthouden titel (optioneel)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "weergave(n)" + }, + "account_page": { + "title": "Accountinstellingen", + "description": "Beheer je accountvoorkeuren en beveiliging", + "tabs": { + "profile": "Profiel", + "security": "Beveiliging", + "developer": "Ontwikkelaar", + "danger_zone": "Gevarenzone" + }, + "profile_info": { + "title": "Profielinformatie", + "description": "Werk je persoonlijke gegevens bij", + "first_name_label": "Voornaam", + "last_name_label": "Achternaam", + "username_label": "Gebruikersnaam", + "email_label": "E-mailadres", + "saving_button": "Opslaan...", + "save_changes_button": "Wijzigingen opslaan" + }, + "profile_settings": { + "username_taken": "Gebruikersnaam is al in gebruik" + }, + "security_settings": { + "title": "Beveiligingsinstellingen", + "description": "Beheer je wachtwoord en beveiligingsvoorkeuren", + "change_password_title": "Wachtwoord wijzigen", + "current_password_label": "Huidig wachtwoord", + "current_password_placeholder": "Huidig wachtwoord invoeren", + "new_password_label": "Nieuw wachtwoord", + "new_password_placeholder": "Voer nieuw wachtwoord in", + "confirm_new_password_label": "Bevestig nieuw wachtwoord", + "confirm_new_password_placeholder": "Bevestig nieuw wachtwoord", + "password_mismatch_alert": "Nieuwe wachtwoorden komen niet overeen", + "changing_password_button": "Bezig met wijzigen...", + "change_password_button": "Wachtwoord wijzigen", + "password_change_success": "Wachtwoord succesvol gewijzigd!", + "password_change_error": "Kan wachtwoord niet wijzigen. Probeer het opnieuw." + }, + "two_factor": { + "title": "Tweefactorauthenticatie", + "description": "Voeg een extra beveiligingslaag toe aan je account", + "enabled": "Ingeschakeld", + "disabled": "Niet ingeschakeld", + "setup_button": "2FA instellen", + "disable_button": "2FA uitschakelen", + "enter_password_to_enable": "Voer je wachtwoord in om tweefactorauthenticatie in te schakelen.", + "continue": "Doorgaan", + "scan_qr_code": "Scan deze QR-code met je authenticator-app (Google Authenticator, Authy, enz.).", + "manual_entry_hint": "Of voer deze code handmatig in je authenticator-app in:", + "enter_verification_code": "Voer de 6-cijferige code uit je authenticator-app in om de installatie te verifiëren.", + "verification_code": "Verificatiecode", + "verify_and_enable": "Verifiëren en inschakelen", + "back": "Terug", + "disable_title": "Tweefactorauthenticatie uitschakelen", + "disable_warning": "Als je 2FA uitschakelt, wordt je account minder veilig. Je moet je wachtwoord invoeren om te bevestigen.", + "invalid_password": "Ongeldig wachtwoord. Probeer het opnieuw.", + "invalid_code": "Ongeldige verificatiecode. Probeer het opnieuw.", + "enable_error": "Kan 2FA niet inschakelen. Probeer het opnieuw.", + "verify_error": "Kan 2FA-code niet verifiëren. Probeer het opnieuw.", + "disable_error": "Kan 2FA niet uitschakelen. Probeer het opnieuw.", + "backup_codes_title": "Back-upcodes", + "backup_codes_description": "Bewaar deze back-upcodes op een veilige plaats. Je kunt ze gebruiken om toegang te krijgen tot je account als je de toegang tot je authenticator-app kwijt bent.", + "backup_codes_warning": "Elke code kan slechts één keer worden gebruikt. Bewaar ze op een veilige plaats!", + "backup_codes_saved": "Ik heb mijn back-upcodes opgeslagen" + }, + "danger_zone": { + "title": "Gevarenzone", + "description": "Onomkeerbare en destructieve acties", + "delete_account_title": "Account verwijderen", + "delete_account_description": "Zodra je je account verwijdert, is er geen weg terug. Hierdoor worden je account, al je geheimen en alle bijbehorende gegevens permanent verwijderd. Deze actie kan niet ongedaan worden gemaakt.", + "delete_account_bullet1": "Al je geheimen worden permanent verwijderd", + "delete_account_bullet2": "Je accountgegevens worden van onze servers verwijderd", + "delete_account_bullet3": "Alle gedeelde geheime links worden ongeldig", + "delete_account_bullet4": "Deze actie kan niet ongedaan worden gemaakt", + "delete_account_confirm": "Weet je zeker dat je deze account wilt verwijderen? De actie kan niet ongedaan worden gemaakt.", + "delete_account_button": "Account verwijderen", + "deleting_account_button": "Account verwijderen..." + }, + "developer": { + "title": "API-sleutels", + "description": "Beheer API-sleutels voor programmatische toegang", + "create_key": "Sleutel aanmaken", + "create_key_title": "API-sleutel aanmaken", + "key_name": "Sleutelnaam", + "key_name_placeholder": "bijv. Mijn integratie", + "expiration": "Vervaldatum", + "never_expires": "Vervalt nooit", + "expires_30_days": "30 dagen", + "expires_90_days": "90 dagen", + "expires_1_year": "1 jaar", + "create_button": "Aanmaken", + "name_required": "Sleutelnaam is vereist", + "create_error": "Kan API-sleutel niet aanmaken", + "key_created": "API-sleutel aangemaakt!", + "key_warning": "Kopieer deze sleutel nu. Je kunt deze niet opnieuw bekijken.", + "dismiss": "Ik heb de sleutel gekopieerd", + "no_keys": "Nog geen API-sleutels. Maak er een aan om te beginnen.", + "created": "Aangemaakt", + "last_used": "Laatst gebruikt", + "expires": "Verloopt", + "docs_hint": "Lees hoe je API-sleutels kunt gebruiken in de", + "api_docs": "API-documentatie" + } + }, + "analytics_page": { + "title": "Statistieken", + "description": "Volg je activiteiten en inzichten op het gebied van het delen van geheimen", + "time_range": { + "last_7_days": "Laatste 7 dagen", + "last_14_days": "Laatste 14 dagen", + "last_30_days": "Laatste 30 dagen" + }, + "total_secrets": "Totaal aantal geheimen", + "from_last_period": "+{{percentage}}% ten opzichte van vorige periode", + "total_views": "Totaal aantal weergaven", + "avg_views_per_secret": "Gem. weergaven/geheim", + "active_secrets": "Actieve geheimen", + "daily_activity": { + "title": "Dagelijkse activiteit", + "description": "Aangemaakte geheimen en weergaven in de loop van de tijd", + "secrets": "Geheimen", + "views": "Weergaven", + "secrets_created": "Geheimen aangemaakt", + "secret_views": "Geheimen weergeven", + "date": "Datum", + "trend": "Verandering", + "vs_previous": "t.o.v. vorige dag", + "no_data": "Er zijn nog geen gegevens beschikbaar." + }, + "locale": "nl-NL", + "top_countries": { + "title": "Toplanden", + "description": "Waar je geheimen worden bekeken", + "views": "weergave(n)" + }, + "secret_types": { + "title": "Soorten geheimen", + "description": "Verdeling naar beschermingsniveau", + "password_protected": "Beveiligd met wachtwoord", + "ip_restricted": "Beperkt tot IP", + "burn_after_time": "Vernietigen als tijd is verlopen" + }, + "expiration_stats": { + "title": "Vervaldatums", + "description": "Hoe lang geheimen doorgaans blijven bestaan", + "one_hour": "1 uur", + "one_day": "1 dag", + "one_week_plus": ">1 week" + }, + "visitor_analytics": { + "title": "Bezoekers", + "description": "Paginaweergaven en unieke bezoekers", + "unique": "Uniek", + "views": "Weergaven", + "date": "Datum", + "trend": "Verandering", + "vs_previous": "vs vorige dag", + "no_data": "Er zijn nog geen bezoekersgegevens beschikbaar." + }, + "secret_requests": { + "total": "Geheime aanvragen", + "fulfilled": "Vervulde aanvragen" + }, + "loading": "Analyses worden geladen...", + "no_permission": "Je hebt geen toestemming om analyses te bekijken.", + "failed_to_fetch": "Kan analysegegevens niet ophalen." + }, + "instance_page": { + "title": "Serverinstellingen", + "description": "Configureer jouw Hemmelig-server", + "managed_mode": { + "title": "Beheerde modus", + "description": "Deze server wordt beheerd via omgevingsvariabelen. Instellingen zijn alleen-lezen." + }, + "tabs": { + "general": "Algemeen", + "security": "Beveiliging", + "organization": "Organisatie", + "webhook": "Webhooks", + "metrics": "Metriek" + }, + "system_status": { + "title": "Systeemstatus", + "description": "Serverstatus en prestatiestatistieken", + "version": "Versie", + "uptime": "Uptime", + "memory": "Geheugen", + "cpu_usage": "CPU-gebruik" + }, + "general_settings": { + "title": "Algemene instellingen", + "description": "Basisconfiguratie van de instantie", + "instance_name_label": "Naam van de server", + "logo_label": "Server logo", + "logo_upload": "Logo uploaden", + "logo_remove": "Logo verwijderen", + "logo_hint": "PNG, JPEG, GIF, SVG of WebP. Max. 512KB.", + "logo_alt": "Server logo", + "logo_invalid_type": "Ongeldig bestandstype. Upload een PNG-, JPEG-, GIF-, SVG- of WebP-afbeelding.", + "logo_too_large": "Bestand is te groot. Maximale grootte is 512KB.", + "default_expiration_label": "Standaard vervaldatum geheim", + "max_secrets_per_user_label": "Max. aantal geheimen per gebruiker", + "max_secret_size_label": "Max. grootte geheim (MB)", + "instance_description_label": "Beschrijving server", + "important_message_label": "Belangrijk bericht", + "important_message_placeholder": "Voer een belangrijk bericht in dat aan alle gebruikers moet worden getoond...", + "important_message_hint": "Dit bericht wordt weergegeven als een waarschuwingsbanner op de startpagina. Ondersteunt markdown-opmaak. Laat leeg om te verbergen.", + "allow_registration_title": "Registratie toestaan", + "allow_registration_description": "Nieuwe gebruikers toestaan zich te registreren", + "email_verification_title": "E-mailverificatie", + "email_verification_description": "E-mailverificatie vereist" + }, + "saving_button": "Opslaan...", + "save_settings_button": "Instellingen opslaan", + "security_settings": { + "title": "Beveiligingsinstellingen", + "description": "Beveiliging en toegangscontroles configureren", + "rate_limiting_title": "Snelheidsbeperking", + "rate_limiting_description": "Beperking van het aantal verzoeken inschakelen", + "max_password_attempts_label": "Maximaal aantal wachtwoordpogingen", + "session_timeout_label": "Time-out sessie (uren)", + "allow_file_uploads_title": "Bestandsuploads toestaan", + "allow_file_uploads_description": "Gebruikers toestaan bestanden aan geheimen toe te voegen" + }, + "email_settings": { + "title": "E-mailinstellingen", + "description": "SMTP en e-mailmeldingen configureren", + "smtp_host_label": "SMTP-host", + "smtp_port_label": "SMTP-poort", + "username_label": "Gebruikersnaam", + "password_label": "Wachtwoord" + }, + "database_info": { + "title": "Database-informatie", + "description": "Databasestatus en statistieken", + "stats_title": "Databasestatistieken", + "total_secrets": "Totaal aantal geheimen:", + "total_users": "Totaal aantal gebruikers:", + "disk_usage": "Schijfgebruik:", + "connection_status_title": "Verbindingsstatus", + "connected": "Verbonden", + "connected_description": "Database is gezond en reageert normaal" + }, + "system_info": { + "title": "Systeeminformatie", + "description": "Servergegevens en onderhoud", + "system_info_title": "Systeeminformatie", + "version": "Versie:", + "uptime": "Uptime:", + "status": "Status:", + "resource_usage_title": "Resourcegebruik", + "memory": "Geheugen:", + "cpu": "CPU:", + "disk": "Schijf:" + }, + "maintenance_actions": { + "title": "Onderhoudsacties", + "description": "Deze acties kunnen de beschikbaarheid van het systeem beïnvloeden. Wees voorzichtig bij het gebruik ervan.", + "restart_service_button": "Service opnieuw starten", + "clear_cache_button": "Cache wissen", + "export_logs_button": "Logs exporteren" + } + }, + "secrets_page": { + "title": "Je geheimen", + "description": "Beheer en controleer je gedeelde geheimen", + "create_secret_button": "Geheim aanmaken", + "search_placeholder": "Geheimen zoeken...", + "filter": { + "all_secrets": "Alle geheimen", + "active": "Actief", + "expired": "Verlopen" + }, + "total_secrets": "Totaal aantal geheimen", + "active_secrets": "Actief", + "expired_secrets": "Verlopen", + "no_secrets_found_title": "Geen geheimen gevonden", + "no_secrets_found_description_filter": "Probeer je zoek- of filtercriteria aan te passen.", + "no_secrets_found_description_empty": "Maak je eerste geheim aan om te beginnen.", + "password_protected": "Wachtwoord", + "files": "bestanden", + "table": { + "secret_header": "Geheim", + "created_header": "Aangemaakt", + "status_header": "Status", + "views_header": "Weergaven", + "actions_header": "Acties", + "untitled_secret": "Geheim zonder titel", + "expired_status": "Verlopen", + "active_status": "Actief", + "never_expires": "Verloopt nooit", + "expired_time": "Verlopen", + "views_left": "resterende weergaven", + "copy_url_tooltip": "URL kopiëren", + "open_secret_tooltip": "Geheim openen", + "delete_secret_tooltip": "Geheim verwijderen", + "delete_confirmation_title": "Weet je het zeker?", + "delete_confirmation_text": "Deze actie kan niet ongedaan worden gemaakt. Hiermee wordt het geheim definitief verwijderd.", + "delete_confirm_button": "Ja, verwijderen", + "delete_cancel_button": "Annuleren" + } + }, + "users_page": { + "title": "Gebruikersbeheer", + "description": "Beheer gebruikers en hun rechten", + "add_user_button": "Gebruiker toevoegen", + "search_placeholder": "Gebruikers zoeken...", + "filter": { + "all_roles": "Alle rollen", + "admin": "Beheerder", + "user": "Gebruiker", + "all_status": "Alle statussen", + "active": "Actief", + "suspended": "Opgeschort", + "pending": "In behandeling" + }, + "total_users": "Totaal aantal gebruikers", + "active_users": "Actief", + "admins": "Beheerders", + "pending_users": "In afwachting", + "no_users_found_title": "Geen gebruikers gevonden", + "no_users_found_description_filter": "Probeer je zoek- of filtercriteria aan te passen.", + "no_users_found_description_empty": "Er zijn nog geen gebruikers toegevoegd.", + "table": { + "user_header": "Gebruiker", + "role_header": "Rol", + "status_header": "Status", + "activity_header": "Activiteit", + "last_login_header": "Laatst aangemeld", + "actions_header": "Acties", + "created_at": "Aangemaakt op" + }, + "status": { + "active": "Actief", + "banned": "Geblokkeerd" + }, + "delete_user_modal": { + "title": "Gebruiker verwijderen", + "confirmation_message": "Weet je zeker dat je de gebruiker {{username}} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "confirm_button": "Verwijderen", + "cancel_button": "Annuleren" + }, + "edit_user_modal": { + "title": "Gebruiker bewerken: {{username}}", + "username_label": "Gebruikersnaam", + "email_label": "E-mail", + "role_label": "Rol", + "banned_label": "Geblokkeerd", + "save_button": "Opslaan", + "cancel_button": "Annuleren" + }, + "add_user_modal": { + "title": "Nieuwe gebruiker toevoegen", + "name_label": "Naam", + "username_label": "Gebruikersnaam", + "email_label": "E-mail", + "password_label": "Wachtwoord", + "role_label": "Rol", + "save_button": "Gebruiker toevoegen", + "cancel_button": "Annuleren" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Terug naar aanmelden", + "check_email_title": "Controleer je e-mail", + "check_email_description": "We hebben een link voor het opnieuw instellen van je wachtwoord verzonden naar {{email}}", + "did_not_receive_email": "Heb je de e-mail niet ontvangen? Controleer je spamfolder of probeer het opnieuw.", + "try_again_button": "Probeer het opnieuw", + "forgot_password_title": "Wachtwoord vergeten?", + "forgot_password_description": "Geen zorgen, we sturen je instructies om je wachtwoord opnieuw in te stellen", + "email_label": "E-mail", + "email_placeholder": "Voer je e-mailadres in", + "email_hint": "Voer het e-mailadres in dat aan je account is gekoppeld", + "sending_button": "Verzenden...", + "reset_password_button": "Wachtwoord opnieuw instellen", + "remember_password": "Wachtwoord onthouden?", + "sign_in_link": "Aanmelden", + "unexpected_error": "Er is een onverwachte fout opgetreden. Probeer het opnieuw." + }, + "login_page": { + "back_to_hemmelig": "Terug naar Hemmelig", + "welcome_back": "Welkom terug bij Hemmelig", + "welcome_back_title": "Welkom terug", + "welcome_back_description": "Meld je aan met je Hemmelig-account", + "username_label": "Gebruikersnaam", + "username_placeholder": "Voer je gebruikersnaam in", + "password_label": "Wachtwoord", + "password_placeholder": "Voer je wachtwoord in", + "forgot_password_link": "Wachtwoord vergeten?", + "signing_in_button": "Aanmelden...", + "sign_in_button": "Aanmelden", + "or_continue_with": "Of doorgaan met", + "continue_with_github": "Doorgaan met GitHub", + "no_account_question": "Heb je nog geen account?", + "sign_up_link": "Registreren", + "unexpected_error": "Er is een onverwachte fout opgetreden. Probeer het opnieuw." + }, + "register_page": { + "back_to_hemmelig": "Terug naar Hemmelig", + "join_hemmelig": "Word lid van Hemmelig om veilig geheimen te delen", + "email_password_disabled_message": "Registratie met e-mail en wachtwoord is uitgeschakeld. Gebruik een van de onderstaande sociale login-opties.", + "create_account_title": "Account aanmaken", + "create_account_description": "Word lid van Hemmelig om veilig geheimen te delen", + "username_label": "Gebruikersnaam", + "username_placeholder": "Kies een gebruikersnaam", + "email_label": "E-mail", + "email_placeholder": "Voer je e-mailadres in", + "password_label": "Wachtwoord", + "password_placeholder": "Maak een wachtwoord aan", + "password_strength_label": "Wachtwoordsterkte", + "password_strength_levels": { + "very_weak": "Zeer zwak", + "weak": "Zwak", + "fair": "Redelijk", + "good": "Goed", + "strong": "Sterk" + }, + "confirm_password_label": "Bevestig wachtwoord", + "confirm_password_placeholder": "Bevestig je wachtwoord", + "passwords_match": "Wachtwoorden komen overeen", + "passwords_do_not_match": "Wachtwoorden komen niet overeen", + "password_mismatch_alert": "Wachtwoorden komen niet overeen", + "creating_account_button": "Account aanmaken...", + "create_account_button": "Account aanmaken", + "or_continue_with": "Of doorgaan met", + "continue_with_github": "Doorgaan met GitHub", + "already_have_account_question": "Heb je al een account?", + "sign_in_link": "Aanmelden", + "invite_code_label": "Uitnodigingscode", + "invite_code_placeholder": "Voer je uitnodigingscode in", + "invite_code_required": "Uitnodigingscode is vereist", + "invalid_invite_code": "Ongeldige uitnodigingscode", + "failed_to_validate_invite": "Kan uitnodigingscode niet valideren", + "unexpected_error": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.", + "email_domain_not_allowed": "E-maildomein niet toegestaan", + "account_already_exists": "Er bestaat al een account met dit e-mailadres. Log in." + }, + "secret_form": { + "failed_to_create_secret": "Kan geheim niet aanmaken: {{errorMessage}}", + "failed_to_upload_file": "Kan bestand niet uploaden: {{fileName}}" + }, + "secret_page": { + "password_label": "Wachtwoord", + "password_placeholder": "Voer wachtwoord in om geheim te bekijken", + "decryption_key_label": "Ontcijferingssleutel", + "decryption_key_placeholder": "Voer de ontcijferingssleutel in", + "view_secret_button": "Geheim bekijken", + "views_remaining_tooltip": "Resterende weergaven: {{count}}", + "loading_message": "Geheim wordt ontsleuteld...", + "files_title": "Bijgevoegde bestanden", + "secret_waiting_title": "Iemand heeft een geheim met je gedeeld", + "secret_waiting_description": "Dit geheim is versleuteld en kan alleen worden bekeken als je op de onderstaande knop klikt.", + "one_view_remaining": "Dit geheim kan nog maar 1 keer worden bekeken", + "views_remaining": "Dit geheim kan nog {{count}} keer worden bekeken", + "view_warning": "Zodra je dit hebt bekeken, kan deze actie niet ongedaan worden gemaakt", + "secret_revealed": "Geheim", + "copy_secret": "Kopiëren naar klembord", + "download": "Downloaden", + "create_your_own": "Maak je eigen geheim", + "encrypted_secret": "Versleuteld geheim", + "unlock_secret": "Geheim ontgrendelen", + "delete_secret": "Geheim verwijderen", + "delete_modal_title": "Geheim verwijderen", + "delete_modal_message": "Weet je zeker dat je dit geheim wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "decryption_failed": "Het ontcijferen van het geheim is mislukt. Controleer je wachtwoord of ontcijferingssleutel.", + "fetch_error": "Er is een fout opgetreden tijdens het ophalen van het geheim. Probeer het opnieuw." + }, + "expiration": { + "28_days": "28 dagen", + "14_days": "14 dagen", + "7_days": "7 dagen", + "3_days": "3 dagen", + "1_day": "1 dag", + "12_hours": "12 uur", + "4_hours": "4 uur", + "1_hour": "1 uur", + "30_minutes": "30 minuten", + "5_minutes": "5 minuten" + }, + "error_display": { + "clear_errors_button_title": "Fouten wissen" + }, + "secret_not_found_page": { + "title": "Geheim niet gevonden", + "message": "Het geheim dat je zoekt bestaat niet, is verlopen of is vernietigd.", + "error_details": "Foutdetails:", + "go_home_button": "Ga naar startpagina" + }, + "organization_page": { + "title": "Organisatie-instellingen", + "description": "Configureer instellingen en toegangscontroles voor de hele organisatie", + "registration_settings": { + "title": "Registratie-instellingen", + "description": "Bepaal hoe gebruikers lid kunnen worden van je organisatie", + "invite_only_title": "Registratie alleen op uitnodiging", + "invite_only_description": "Gebruikers kunnen zich alleen registreren met een geldige uitnodigingscode", + "require_registered_user_title": "Alleen geregistreerde gebruikers", + "require_registered_user_description": "Alleen geregistreerde gebruikers kunnen geheimen aanmaken", + "disable_email_password_signup_title": "E-mail/wachtwoord registratie uitschakelen", + "disable_email_password_signup_description": "Registratie met e-mail en wachtwoord uitschakelen (alleen sociale login)", + "allowed_domains_title": "Toegestane e-maildomeinen", + "allowed_domains_description": "Registratie alleen toestaan van specifieke e-maildomeinen (gescheiden door komma's, bijv. bedrijf.com, org.net)", + "allowed_domains_placeholder": "bedrijf.com, org.net", + "allowed_domains_hint": "Door komma's gescheiden lijst met e-maildomeinen. Laat leeg om alle domeinen toe te staan." + }, + "invite_codes": { + "title": "Uitnodigingscodes", + "description": "Maak en beheer uitnodigingscodes voor nieuwe gebruikers", + "create_invite_button": "Uitnodigingscode maken", + "code_header": "Code", + "uses_header": "Gebruikt", + "expires_header": "Verloopt", + "actions_header": "Acties", + "unlimited": "Onbeperkt", + "never": "Nooit", + "expired": "Verlopen", + "no_invites": "Nog geen uitnodigingscodes", + "no_invites_description": "Maak een uitnodigingscode aan om nieuwe gebruikers te laten registreren", + "copy_tooltip": "Code kopiëren", + "delete_tooltip": "Code verwijderen" + }, + "create_invite_modal": { + "title": "Uitnodigingscode aanmaken", + "max_uses_label": "Maximaal aantal keren gebruiken", + "max_uses_placeholder": "Laat leeg voor onbeperkt", + "expiration_label": "Vervaldatum", + "expiration_options": { + "never": "Nooit", + "24_hours": "24 uur", + "7_days": "7 dagen", + "30_days": "30 dagen" + }, + "cancel_button": "Annuleren", + "create_button": "Aanmaken" + }, + "saving_button": "Bezig met opslaan...", + "save_settings_button": "Instellingen opslaan" + }, + "invites_page": { + "title": "Uitnodigingscodes", + "description": "Beheer uitnodigingscodes voor nieuwe gebruikersregistraties", + "create_invite_button": "Uitnodiging aanmaken", + "loading": "Uitnodigingscodes laden...", + "table": { + "code_header": "Code", + "uses_header": "Gebruikt", + "expires_header": "Verloopt", + "status_header": "Status", + "never": "Nooit" + }, + "status": { + "active": "Actief", + "expired": "Verlopen", + "used": "Gebruikt", + "inactive": "Inactief" + }, + "no_invites": "Nog geen uitnodigingscodes", + "create_modal": { + "title": "Uitnodigingscode aanmaken", + "max_uses_label": "Maximaal aantal keren te gebruiken", + "expires_in_label": "Verloopt over (dagen)" + }, + "delete_modal": { + "title": "Uitnodigingscode deactiveren", + "confirm_text": "Deactiveren", + "cancel_text": "Annuleren", + "message": "Weet je zeker dat je uitnodigingscode {{code}} wilt deactiveren? Deze actie kan niet ongedaan worden gemaakt." + }, + "toast": { + "created": "Uitnodigingscode aangemaakt", + "deactivated": "Uitnodigingscode gedeactiveerd", + "copied": "Uitnodigingscode gekopieerd naar klembord", + "fetch_error": "Kan uitnodigingscodes niet ophalen", + "create_error": "Kan uitnodigingscode niet aanmaken", + "delete_error": "Kan uitnodigingscode niet deactiveren" + } + }, + "social_login": { + "continue_with": "Doorgaan met {{provider}}", + "sign_up_with": "Registreren met {{provider}}" + }, + "setup_page": { + "title": "Welkom bij Hemmelig", + "description": "Maak je beheerdersaccount aan om te beginnen", + "name_label": "Volledige naam", + "name_placeholder": "Voer je volledige naam in", + "username_label": "Gebruikersnaam", + "username_placeholder": "Kies een gebruikersnaam", + "email_label": "E-mailadres", + "email_placeholder": "Voer je e-mailadres in", + "password_label": "Wachtwoord", + "password_placeholder": "Maak een wachtwoord aan (minimaal 8 tekens)", + "confirm_password_label": "Bevestig wachtwoord", + "confirm_password_placeholder": "Bevestig je wachtwoord", + "create_admin": "Beheerdersaccount aanmaken", + "creating": "Account aanmaken... ", + "success": "Beheerdersaccount succesvol aangemaakt! Log in.", + "error": "Kan beheerdersaccount niet aanmaken", + "passwords_mismatch": "Wachtwoorden komen niet overeen", + "password_too_short": "Wachtwoord moet minimaal 8 tekens bevatten", + "note": "Deze instelling kan slechts één keer worden voltooid. Het beheerdersaccount heeft volledige toegang om deze instantie te beheren." + }, + "theme_toggle": { + "switch_to_light": "Overschakelen naar lichte modus", + "switch_to_dark": "Overschakelen naar donkere modus" + }, + "webhook_settings": { + "title": "Webhook-meldingen", + "description": "Externe services op de hoogte stellen wanneer geheimen worden bekeken of vernietigd", + "enable_webhooks_title": "Webhooks inschakelen", + "enable_webhooks_description": "Verstuur HTTP POST-verzoeken naar je webhook-URL wanneer er gebeurtenissen plaatsvinden", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://example.com/webhook", + "webhook_url_hint": "De URL waarnaar webhook-payloads worden verzonden", + "webhook_secret_label": "Webhook-geheim", + "webhook_secret_placeholder": "Voer een geheim in voor HMAC-ondertekening", + "webhook_secret_hint": "Wordt gebruikt om webhook-payloads te ondertekenen met HMAC-SHA256. De handtekening wordt verzonden in de X-Hemmelig-Signature-header.", + "events_title": "Webhook-gebeurtenissen", + "on_view_title": "Geheim bekeken", + "on_view_description": "Stuur een webhook wanneer een geheim wordt bekeken", + "on_burn_title": "Geheim vernietigd", + "on_burn_description": "Stuur een webhook wanneer een geheim wordt vernietigd of verwijderd" + }, + "metrics_settings": { + "title": "Prometheus-metriek", + "description": "Metrieken beschikbaar maken voor monitoring met Prometheus", + "enable_metrics_title": "Prometheus-metriek inschakelen", + "enable_metrics_description": "Een /api/metrics-eindpunt beschikbaar maken voor Prometheus-scraping", + "metrics_secret_label": "Metriek-geheim", + "metrics_secret_placeholder": "Voer een geheim in voor authenticatie", + "metrics_secret_hint": "Wordt gebruikt als Bearer-token om verzoeken aan het metriek-eindpunt te authenticeren. Laat leeg voor geen authenticatie (niet aanbevolen).", + "endpoint_info_title": "Eindpuntinformatie", + "endpoint_info_description": "Na inschakeling zijn metrieken beschikbaar op:", + "endpoint_auth_hint": "Voeg het geheim toe als Bearer-token in de Authorization-header bij het ophalen van metrieken." + }, + "verify_2fa_page": { + "back_to_login": "Terug naar aanmelden", + "title": "Tweefactorauthenticatie", + "description": "Voer de 6-cijferige code uit je authenticator-app in", + "enter_code_hint": "Voer de code uit je authenticator-app in", + "verifying": "Verifiëren...", + "verify_button": "Verifiëren", + "invalid_code": "Ongeldige verificatiecode. Probeer het opnieuw.", + "unexpected_error": "Er is een onverwachte fout opgetreden. Probeer het opnieuw." + }, + "common": { + "error": "Fout", + "cancel": "Annuleren", + "confirm": "Bevestigen", + "ok": "OK", + "delete": "Verwijderen", + "deleting": "Bezig met verwijderen...", + "loading": "Bezig met laden..." + }, + "pagination": { + "showing": "Toont {{start}} tot {{end}} van {{total}} resultaten", + "previous_page": "Vorige pagina", + "next_page": "Volgende pagina" + }, + "not_found_page": { + "title": "Pagina niet gevonden", + "message": "Deze pagina is in rook opgegaan, net als onze geheimen.", + "hint": "De pagina die je zoekt bestaat niet of is verplaatst.", + "go_home_button": "Naar startpagina", + "create_secret_button": "Geheim aanmaken" + }, + "error_boundary": { + "title": "Er is iets misgegaan", + "message": "Er is een onverwachte fout opgetreden tijdens het verwerken van je verzoek.", + "hint": "Maak je geen zorgen, je geheimen zijn nog steeds veilig. Probeer de pagina te verversen.", + "error_details": "Foutdetails:", + "unknown_error": "Er is een onbekende fout opgetreden", + "try_again_button": "Opnieuw proberen", + "go_home_button": "Naar startpagina" + }, + "secret_requests_page": { + "title": "Geheimverzoeken", + "description": "Vraag geheimen aan van anderen via beveiligde links", + "create_request_button": "Verzoek aanmaken", + "no_requests": "Nog geen geheimverzoeken. Maak er een om te beginnen.", + "table": { + "title_header": "Titel", + "status_header": "Status", + "secret_expiry_header": "Geheim vervalt", + "link_expires_header": "Link vervalt", + "copy_link_tooltip": "Makerlink kopiëren", + "view_secret_tooltip": "Geheim bekijken", + "cancel_tooltip": "Verzoek annuleren" + }, + "status": { + "pending": "In afwachting", + "fulfilled": "Voltooid", + "expired": "Verlopen", + "cancelled": "Geannuleerd" + }, + "time": { + "days": "{{count}} dag", + "days_plural": "{{count}} dagen", + "hours": "{{count}} uur", + "hours_plural": "{{count}} uren", + "minutes": "{{count}} minuut", + "minutes_plural": "{{count}} minuten" + }, + "link_modal": { + "title": "Makerlink", + "description": "Stuur deze link naar de persoon die het geheim moet verstrekken. Ze kunnen het geheim invoeren en versleutelen via deze link.", + "copy_button": "Link kopiëren", + "close_button": "Sluiten", + "warning": "Deze link kan slechts één keer worden gebruikt. Zodra een geheim is ingediend, werkt de link niet meer." + }, + "cancel_modal": { + "title": "Verzoek annuleren", + "message": "Weet je zeker dat je het verzoek \"{{title}}\" wilt annuleren? Deze actie kan niet ongedaan worden gemaakt.", + "confirm_text": "Verzoek annuleren", + "cancel_text": "Verzoek behouden" + }, + "toast": { + "copied": "Link gekopieerd naar klembord", + "cancelled": "Verzoek geannuleerd", + "fetch_error": "Kon verzoekdetails niet ophalen", + "cancel_error": "Kon verzoek niet annuleren" + } + }, + "create_request_page": { + "title": "Geheimverzoek aanmaken", + "description": "Vraag een geheim aan van iemand door een beveiligde link te genereren die ze kunnen gebruiken om het in te dienen", + "back_button": "Terug naar geheimverzoeken", + "form": { + "title_label": "Verzoektitel", + "title_placeholder": "bijv. AWS-inloggegevens voor Project X", + "description_label": "Beschrijving (optioneel)", + "description_placeholder": "Geef aanvullende context over wat je nodig hebt...", + "link_validity_label": "Linkgeldigheid", + "link_validity_hint": "Hoe lang de makerlink actief blijft", + "secret_settings_title": "Geheimínstellingen", + "secret_settings_description": "Deze instellingen worden toegepast op het geheim zodra het is aangemaakt", + "secret_expiration_label": "Geheim vervalt", + "max_views_label": "Maximale weergaven", + "password_label": "Wachtwoordbeveiliging (optioneel)", + "password_placeholder": "Voer een wachtwoord in (min 5 tekens)", + "password_hint": "Ontvangers hebben dit wachtwoord nodig om het geheim te bekijken", + "ip_restriction_label": "IP-beperking (optioneel)", + "ip_restriction_placeholder": "192.168.1.0/24 of 203.0.113.5", + "prevent_burn_label": "Automatisch verwijderen voorkomen (geheim behouden na max weergaven)", + "webhook_title": "Webhook-notificatie (optioneel)", + "webhook_description": "Ontvang een melding wanneer het geheim wordt ingediend", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://jouw-server.com/webhook", + "webhook_url_hint": "HTTPS aanbevolen. Een melding wordt verzonden wanneer het geheim is aangemaakt.", + "creating_button": "Aanmaken...", + "create_button": "Verzoek aanmaken" + }, + "validity": { + "30_days": "30 dagen", + "14_days": "14 dagen", + "7_days": "7 dagen", + "3_days": "3 dagen", + "1_day": "1 dag", + "12_hours": "12 uur", + "1_hour": "1 uur" + }, + "success": { + "title": "Verzoek aangemaakt!", + "description": "Deel de makerlink met de persoon die het geheim moet verstrekken", + "creator_link_label": "Makerlink", + "webhook_secret_label": "Webhook-geheim", + "webhook_secret_warning": "Sla dit geheim nu op! Het wordt niet opnieuw getoond. Gebruik het om webhook-handtekeningen te verifiëren.", + "expires_at": "Link vervalt: {{date}}", + "create_another_button": "Nog een verzoek aanmaken", + "view_all_button": "Alle verzoeken bekijken" + }, + "toast": { + "created": "Geheimverzoek succesvol aangemaakt", + "create_error": "Kon geheimverzoek niet aanmaken", + "copied": "Gekopieerd naar klembord" + } + }, + "request_secret_page": { + "loading": "Verzoek laden...", + "error": { + "title": "Verzoek niet beschikbaar", + "invalid_link": "Deze link is ongeldig of is gemanipuleerd.", + "not_found": "Dit verzoek is niet gevonden of de link is ongeldig.", + "already_fulfilled": "Dit verzoek is al voltooid of verlopen.", + "generic": "Er is een fout opgetreden bij het laden van het verzoek.", + "go_home_button": "Naar startpagina" + }, + "form": { + "title": "Een geheim indienen", + "description": "Iemand heeft je gevraagd om een geheim veilig met hen te delen", + "password_protected_note": "Dit geheim wordt met een wachtwoord beveiligd", + "encryption_note": "Je geheim wordt in je browser versleuteld voordat het wordt verzonden. De decoderingssleutel wordt alleen opgenomen in de uiteindelijke URL die je deelt.", + "submitting_button": "Versleutelen & indienen...", + "submit_button": "Geheim indienen" + }, + "success": { + "title": "Geheim aangemaakt!", + "description": "Je geheim is versleuteld en veilig opgeslagen", + "decryption_key_label": "Ontcijferingssleutel", + "warning": "Belangrijk: Kopieer deze ontcijferingssleutel nu en stuur deze naar de aanvrager. Dit is de enige keer dat je deze zult zien!", + "manual_send_note": "Je moet deze ontcijferingssleutel handmatig versturen naar de persoon die het geheim heeft aangevraagd. Ze hebben de geheime URL al in hun dashboard.", + "create_own_button": "Je eigen geheim aanmaken" + }, + "toast": { + "created": "Geheim succesvol ingediend", + "create_error": "Kon geheim niet indienen", + "copied": "Gekopieerd naar klembord" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/no/no.json b/src/i18n/locales/no/no.json new file mode 100644 index 0000000..f7d4b3b --- /dev/null +++ b/src/i18n/locales/no/no.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Maler", + "description": "Hurtigstart med en mal", + "templates": { + "credentials": "Påloggingsinformasjon", + "api_key": "API-nøkkel", + "database": "Database", + "server": "Servertilgang", + "credit_card": "Betalingskort", + "email": "E-postkonto" + } + }, + "editor": { + "tooltips": { + "copy_text": "Kopier som ren tekst", + "copy_html": "Kopier som HTML", + "copy_base64": "Kopier som Base64", + "bold": "Fet", + "italic": "Kursiv", + "strikethrough": "Gjennomstreking", + "inline_code": "Innebygd kode", + "link": "Lenke", + "remove_link": "Fjern lenke", + "insert_password": "Sett inn passord", + "paragraph": "Avsnitt", + "heading1": "Overskrift 1", + "heading2": "Overskrift 2", + "heading3": "Overskrift 3", + "bullet_list": "Punktliste", + "numbered_list": "Nummerert liste", + "blockquote": "Sitatblokk", + "code_block": "Kodeblokk", + "undo": "Angre", + "redo": "Gjør om" + }, + "copy_success": { + "html": "HTML kopiert!", + "text": "Tekst kopiert!", + "base64": "Base64 kopiert!" + }, + "link_modal": { + "title": "Legg til lenke", + "url_label": "URL", + "url_placeholder": "Skriv inn URL", + "cancel": "Avbryt", + "update": "Oppdater", + "insert": "Sett inn" + }, + "password_modal": { + "title": "Generer passord", + "length_label": "Passordlengde", + "options_label": "Alternativer", + "include_numbers": "Tall", + "include_symbols": "Symboler", + "include_uppercase": "Store bokstaver", + "include_lowercase": "Små bokstaver", + "generated_password": "Generert passord", + "refresh": "Oppdater", + "cancel": "Avbryt", + "insert": "Sett inn", + "copied_and_added": "Passord lagt til og kopiert til utklippstavlen", + "added": "Passord lagt til" + }, + "formatting_tools": "Formateringsverktøy", + "character_count": "tegn" + }, + "create_button": { + "creating_secret": "Oppretter hemmelighet...", + "create": "Opprett" + }, + "file_upload": { + "sign_in_to_upload": "Logg inn for å laste opp filer", + "sign_in": "Logg inn", + "drop_files_here": "Slipp filer her", + "drag_and_drop": "Dra og slipp en fil, eller klikk for å velge", + "uploading": "Laster opp...", + "upload_file": "Last opp fil", + "file_too_large": "Filen \"{{fileName}}\" ({{fileSize}} MB) overstiger maksimal størrelse på {{maxSize}} MB", + "max_size_exceeded": "Total filstørrelse overstiger maksimum på {{maxSize}} MB" + }, + "footer": { + "tagline": "Hemmelig, [heˈm(ɛ)li], betyr 'secret' på norsk", + "privacy": "Personvern", + "terms": "Vilkår", + "api": "API", + "managed_hosting": "Managed Hosting", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Hjem", + "sign_in": "Logg inn", + "sign_up": "Registrer deg", + "dashboard": "Oversikt", + "hero_text_part1": "Del hemmeligheter sikkert med krypterte meldinger som automatisk", + "hero_text_part2": " selvdestruerer", + "hero_text_part3": " etter å ha blitt lest." + }, + "dashboard_layout": { + "secrets": "Hemmeligheter", + "secret_requests": "Hemmeligforespørsler", + "account": "Konto", + "analytics": "Analyse", + "users": "Brukere", + "invites": "Invitasjoner", + "instance": "Instans", + "sign_out": "Logg ut", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Hemmelighet opprettet!", + "secret_created_description": "Hemmeligheten din er nå tilgjengelig på følgende URL. Ta vare på dekrypteringsnøkkelen, da den ikke kan gjenopprettes.", + "secret_url_label": "Hemmelig URL", + "decryption_key_label": "Dekrypteringsnøkkel", + "password_label": "Passord", + "create_new_secret_button": "Opprett ny hemmelighet", + "copy_url_button": "Kopier URL", + "burn_secret_button": "Brenn hemmelighet", + "max_secrets_per_user_info": "Du kan opprette opptil {{count}} hemmeligheter.", + "failed_to_burn": "Kunne ikke brenne hemmeligheten. Vennligst prøv igjen." + }, + "security_settings": { + "security_title": "Sikkerhet", + "security_description": "Konfigurer sikkerhetsinnstillinger for hemmeligheten din", + "remember_settings": "Husk", + "private_title": "Privat", + "private_description": "Private hemmeligheter er kryptert og kan kun vises med dekrypteringsnøkkelen og/eller passord.", + "expiration_title": "Utløpsdato", + "expiration_burn_after_time_description": "Angi når hemmeligheten skal ødelegges", + "expiration_default_description": "Angi hvor lenge hemmeligheten skal være tilgjengelig", + "max_views_title": "Maks visninger", + "burn_after_time_mode_title": "Brenn etter tid-modus", + "burn_after_time_mode_description": "Hemmeligheten vil bli ødelagt etter at tiden utløper, uavhengig av hvor mange ganger den vises.", + "password_protection_title": "Passordbeskyttelse", + "password_protection_description": "Legg til et ekstra lag med sikkerhet med et passord", + "enter_password_label": "Skriv inn passord", + "password_placeholder": "Skriv inn et sikkert passord...", + "password_hint": "Minimum 5 tegn. Mottakere trenger dette passordet for å se hemmeligheten", + "password_error": "Passordet må være på minst 5 tegn", + "ip_restriction_title": "Begrens etter IP eller CIDR", + "ip_restriction_description": "CIDR-inndata lar brukere spesifisere IP-adresseområder som kan få tilgang til hemmeligheten.", + "ip_address_cidr_label": "IP-adresse eller CIDR-område", + "ip_address_cidr_placeholder": "192.168.1.0/24 eller 203.0.113.5", + "ip_address_cidr_hint": "Kun forespørsler fra disse IP-adressene vil kunne få tilgang til hemmeligheten", + "burn_after_time_title": "Brenn etter at tiden utløper", + "burn_after_time_description": "Brenn hemmeligheten kun etter at tiden utløper" + }, + "title_field": { + "placeholder": "Tittel", + "hint": "Gi hemmeligheten din en tittel (valgfritt)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "visninger" + }, + "account_page": { + "title": "Kontoinnstillinger", + "description": "Administrer dine kontopreferanser og sikkerhet", + "tabs": { + "profile": "Profil", + "security": "Sikkerhet", + "developer": "Utvikler", + "danger_zone": "Faresone" + }, + "profile_info": { + "title": "Profilinformasjon", + "description": "Oppdater din personlige informasjon", + "first_name_label": "Fornavn", + "last_name_label": "Etternavn", + "username_label": "Brukernavn", + "email_label": "E-postadresse", + "saving_button": "Lagrer...", + "save_changes_button": "Lagre endringer" + }, + "profile_settings": { + "username_taken": "Brukernavnet er allerede opptatt" + }, + "security_settings": { + "title": "Sikkerhetsinnstillinger", + "description": "Administrer passordet ditt og sikkerhetspreferanser", + "change_password_title": "Bytt passord", + "current_password_label": "Nåværende passord", + "current_password_placeholder": "Skriv inn nåværende passord", + "new_password_label": "Nytt passord", + "new_password_placeholder": "Skriv inn nytt passord", + "confirm_new_password_label": "Bekreft nytt passord", + "confirm_new_password_placeholder": "Bekreft nytt passord", + "password_mismatch_alert": "Nye passord samsvarer ikke", + "changing_password_button": "Endrer...", + "change_password_button": "Bytt passord", + "password_change_success": "Passordet ble endret!", + "password_change_error": "Kunne ikke endre passord. Vennligst prøv igjen." + }, + "two_factor": { + "title": "Tofaktorautentisering", + "description": "Legg til et ekstra lag med sikkerhet på kontoen din", + "enabled": "Aktivert", + "disabled": "Ikke aktivert", + "setup_button": "Konfigurer 2FA", + "disable_button": "Deaktiver 2FA", + "enter_password_to_enable": "Skriv inn passordet ditt for å aktivere tofaktorautentisering.", + "continue": "Fortsett", + "scan_qr_code": "Skann denne QR-koden med autentiseringsappen din (Google Authenticator, Authy, osv.).", + "manual_entry_hint": "Eller skriv inn denne koden manuelt i autentiseringsappen din:", + "enter_verification_code": "Skriv inn den 6-sifrede koden fra autentiseringsappen for å bekrefte oppsettet.", + "verification_code": "Bekreftelseskode", + "verify_and_enable": "Bekreft og aktiver", + "back": "Tilbake", + "disable_title": "Deaktiver tofaktorautentisering", + "disable_warning": "Deaktivering av 2FA vil gjøre kontoen din mindre sikker. Du må skrive inn passordet ditt for å bekrefte.", + "invalid_password": "Ugyldig passord. Vennligst prøv igjen.", + "invalid_code": "Ugyldig bekreftelseskode. Vennligst prøv igjen.", + "enable_error": "Kunne ikke aktivere 2FA. Vennligst prøv igjen.", + "verify_error": "Kunne ikke bekrefte 2FA-kode. Vennligst prøv igjen.", + "disable_error": "Kunne ikke deaktivere 2FA. Vennligst prøv igjen.", + "backup_codes_title": "Sikkerhetskoder", + "backup_codes_description": "Lagre disse sikkerhetskodene på et trygt sted. Du kan bruke dem til å få tilgang til kontoen din hvis du mister tilgangen til autentiseringsappen.", + "backup_codes_warning": "Hver kode kan kun brukes én gang. Lagre dem sikkert!", + "backup_codes_saved": "Jeg har lagret mine sikkerhetskoder" + }, + "danger_zone": { + "title": "Faresone", + "description": "Irreversible og destruktive handlinger", + "delete_account_title": "Slett konto", + "delete_account_description": "Når du sletter kontoen din, er det ingen vei tilbake. Dette vil permanent slette kontoen din, alle hemmelighetene dine, og fjerne alle tilknyttede data. Denne handlingen kan ikke angres.", + "delete_account_bullet1": "Alle hemmelighetene dine vil bli permanent slettet", + "delete_account_bullet2": "Kontodataene dine vil bli fjernet fra våre servere", + "delete_account_bullet3": "Alle delte hemmelighetslenker vil bli ugyldige", + "delete_account_bullet4": "Denne handlingen kan ikke reverseres", + "delete_account_confirm": "Er du sikker på at du vil slette kontoen din? Denne handlingen kan ikke angres.", + "delete_account_button": "Slett konto", + "deleting_account_button": "Sletter konto..." + }, + "developer": { + "title": "API-nøkler", + "description": "Administrer API-nøkler for programmatisk tilgang", + "create_key": "Opprett nøkkel", + "create_key_title": "Opprett API-nøkkel", + "key_name": "Nøkkelnavn", + "key_name_placeholder": "f.eks., Min integrasjon", + "expiration": "Utløpsdato", + "never_expires": "Utløper aldri", + "expires_30_days": "30 dager", + "expires_90_days": "90 dager", + "expires_1_year": "1 år", + "create_button": "Opprett", + "name_required": "Nøkkelnavn er påkrevd", + "create_error": "Kunne ikke opprette API-nøkkel", + "key_created": "API-nøkkel opprettet!", + "key_warning": "Kopier denne nøkkelen nå. Du vil ikke kunne se den igjen.", + "dismiss": "Jeg har kopiert nøkkelen", + "no_keys": "Ingen API-nøkler ennå. Opprett en for å komme i gang.", + "created": "Opprettet", + "last_used": "Sist brukt", + "expires": "Utløper", + "docs_hint": "Lær hvordan du bruker API-nøkler i", + "api_docs": "API-dokumentasjonen" + } + }, + "analytics_page": { + "title": "Analyse", + "description": "Spor din hemmelighetsdelingsaktivitet og innsikt", + "time_range": { + "last_7_days": "Siste 7 dager", + "last_14_days": "Siste 14 dager", + "last_30_days": "Siste 30 dager" + }, + "total_secrets": "Totalt antall hemmeligheter", + "from_last_period": "+{{percentage}}% fra forrige periode", + "total_views": "Totalt antall visninger", + "avg_views_per_secret": "Gj.snitt visninger/hemmelighet", + "active_secrets": "Aktive hemmeligheter", + "daily_activity": { + "title": "Daglig aktivitet", + "description": "Hemmeligheter opprettet og visninger over tid", + "secrets": "Hemmeligheter", + "views": "Visninger", + "secrets_created": "Hemmeligheter opprettet", + "secret_views": "Hemmelighetsvisninger", + "date": "Dato", + "trend": "Endring", + "vs_previous": "vs forrige dag", + "no_data": "Ingen aktivitetsdata tilgjengelig ennå." + }, + "locale": "no-NO", + "top_countries": { + "title": "Topp land", + "description": "Hvor hemmelighetene dine blir sett", + "views": "visninger" + }, + "secret_types": { + "title": "Hemmelighetstyper", + "description": "Fordeling etter beskyttelsesnivå", + "password_protected": "Passordbeskyttet", + "ip_restricted": "IP-begrenset", + "burn_after_time": "Brenn etter tid" + }, + "expiration_stats": { + "title": "Utløpsstatistikk", + "description": "Hvor lenge hemmeligheter vanligvis varer", + "one_hour": "1 time", + "one_day": "1 dag", + "one_week_plus": "1 uke+" + }, + "visitor_analytics": { + "title": "Besøksanalyse", + "description": "Sidevisninger og unike besøkende", + "unique": "Unike", + "views": "Visninger", + "date": "Dato", + "trend": "Endring", + "vs_previous": "vs forrige dag", + "no_data": "Ingen besøksdata tilgjengelig ennå." + }, + "secret_requests": { + "total": "Hemmeligforespørsler", + "fulfilled": "Oppfylte forespørsler" + }, + "loading": "Laster analyse...", + "no_permission": "Du har ikke tillatelse til å se analyse.", + "failed_to_fetch": "Kunne ikke hente analysedata." + }, + "instance_page": { + "title": "Instansinnstillinger", + "description": "Konfigurer din Hemmelig-instans", + "managed_mode": { + "title": "Administrert modus", + "description": "Denne instansen administreres via miljøvariabler. Innstillinger er skrivebeskyttet." + }, + "tabs": { + "general": "Generelt", + "security": "Sikkerhet", + "organization": "Organisasjon", + "webhook": "Webhooks", + "metrics": "Metrikk" + }, + "system_status": { + "title": "Systemstatus", + "description": "Instanshelse og ytelsesmetrikk", + "version": "Versjon", + "uptime": "Oppetid", + "memory": "Minne", + "cpu_usage": "CPU-bruk" + }, + "general_settings": { + "title": "Generelle innstillinger", + "description": "Grunnleggende instanskonfigurasjon", + "instance_name_label": "Instansnavn", + "logo_label": "Instanslogo", + "logo_upload": "Last opp logo", + "logo_remove": "Fjern logo", + "logo_hint": "PNG, JPEG, GIF, SVG eller WebP. Maks 512KB.", + "logo_alt": "Instanslogo", + "logo_invalid_type": "Ugyldig filtype. Vennligst last opp et PNG-, JPEG-, GIF-, SVG- eller WebP-bilde.", + "logo_too_large": "Filen er for stor. Maksimal størrelse er 512KB.", + "default_expiration_label": "Standard utløpstid for hemmeligheter", + "max_secrets_per_user_label": "Maks hemmeligheter per bruker", + "max_secret_size_label": "Maks hemmelighetsstørrelse (MB)", + "instance_description_label": "Instansbeskrivelse", + "important_message_label": "Viktig melding", + "important_message_placeholder": "Skriv inn en viktig melding som skal vises til alle brukere...", + "important_message_hint": "Denne meldingen vil vises som et varselbanner på hjemmesiden. Støtter markdown-formatering. La stå tomt for å skjule.", + "allow_registration_title": "Tillat registrering", + "allow_registration_description": "Tillat nye brukere å registrere seg", + "email_verification_title": "E-postverifisering", + "email_verification_description": "Krev e-postverifisering" + }, + "saving_button": "Lagrer...", + "save_settings_button": "Lagre innstillinger", + "security_settings": { + "title": "Sikkerhetsinnstillinger", + "description": "Konfigurer sikkerhet og tilgangskontroller", + "rate_limiting_title": "Hastighetsbegrensning", + "rate_limiting_description": "Aktiver forespørselshastighetsbegrensning", + "max_password_attempts_label": "Maks passordforsøk", + "session_timeout_label": "Økttidsavbrudd (timer)", + "allow_file_uploads_title": "Tillat filopplasting", + "allow_file_uploads_description": "Tillat brukere å legge ved filer til hemmeligheter" + }, + "email_settings": { + "title": "E-postinnstillinger", + "description": "Konfigurer SMTP og e-postvarsler", + "smtp_host_label": "SMTP-vert", + "smtp_port_label": "SMTP-port", + "username_label": "Brukernavn", + "password_label": "Passord" + }, + "database_info": { + "title": "Databaseinformasjon", + "description": "Databasestatus og statistikk", + "stats_title": "Databasestatistikk", + "total_secrets": "Totalt antall hemmeligheter:", + "total_users": "Totalt antall brukere:", + "disk_usage": "Diskgebruk:", + "connection_status_title": "Tilkoblingsstatus", + "connected": "Tilkoblet", + "connected_description": "Databasen er sunn og svarer normalt" + }, + "system_info": { + "title": "Systeminformasjon", + "description": "Serverdetaljer og vedlikehold", + "system_info_title": "Systeminfo", + "version": "Versjon:", + "uptime": "Oppetid:", + "status": "Status:", + "resource_usage_title": "Ressursbruk", + "memory": "Minne:", + "cpu": "CPU:", + "disk": "Disk:" + }, + "maintenance_actions": { + "title": "Vedlikeholdshandlinger", + "description": "Disse handlingene kan påvirke systemets tilgjengelighet. Bruk med forsiktighet.", + "restart_service_button": "Start tjenesten på nytt", + "clear_cache_button": "Tøm hurtigbuffer", + "export_logs_button": "Eksporter logger" + } + }, + "secrets_page": { + "title": "Dine hemmeligheter", + "description": "Administrer og overvåk dine delte hemmeligheter", + "create_secret_button": "Opprett hemmelighet", + "search_placeholder": "Søk i hemmeligheter...", + "filter": { + "all_secrets": "Alle hemmeligheter", + "active": "Aktive", + "expired": "Utløpt" + }, + "total_secrets": "Totalt antall hemmeligheter", + "active_secrets": "Aktive", + "expired_secrets": "Utløpt", + "no_secrets_found_title": "Ingen hemmeligheter funnet", + "no_secrets_found_description_filter": "Prøv å justere søke- eller filterkriteriene.", + "no_secrets_found_description_empty": "Opprett din første hemmelighet for å komme i gang.", + "password_protected": "Passord", + "files": "filer", + "table": { + "secret_header": "Hemmelighet", + "created_header": "Opprettet", + "status_header": "Status", + "views_header": "Visninger", + "actions_header": "Handlinger", + "untitled_secret": "Navnløs hemmelighet", + "expired_status": "Utløpt", + "active_status": "Aktiv", + "never_expires": "Utløper aldri", + "expired_time": "Utløpt", + "views_left": "visninger igjen", + "copy_url_tooltip": "Kopier URL", + "open_secret_tooltip": "Åpne hemmelighet", + "delete_secret_tooltip": "Slett hemmelighet", + "delete_confirmation_title": "Er du sikker?", + "delete_confirmation_text": "Denne handlingen kan ikke angres. Dette vil permanent slette hemmeligheten.", + "delete_confirm_button": "Ja, slett den", + "delete_cancel_button": "Avbryt" + } + }, + "users_page": { + "title": "Brukeradministrasjon", + "description": "Administrer brukere og deres tillatelser", + "add_user_button": "Legg til bruker", + "search_placeholder": "Søk etter brukere...", + "filter": { + "all_roles": "Alle roller", + "admin": "Admin", + "user": "Bruker", + "all_status": "All status", + "active": "Aktiv", + "suspended": "Suspendert", + "pending": "Venter" + }, + "total_users": "Totalt antall brukere", + "active_users": "Aktive", + "admins": "Administratorer", + "pending_users": "Venter", + "no_users_found_title": "Ingen brukere funnet", + "no_users_found_description_filter": "Prøv å justere søke- eller filterkriteriene.", + "no_users_found_description_empty": "Ingen brukere er lagt til ennå.", + "table": { + "user_header": "Bruker", + "role_header": "Rolle", + "status_header": "Status", + "activity_header": "Aktivitet", + "last_login_header": "Siste pålogging", + "actions_header": "Handlinger", + "created_at": "Opprettet den" + }, + "status": { + "active": "Aktiv", + "banned": "Utestengt" + }, + "delete_user_modal": { + "title": "Slett bruker", + "confirmation_message": "Er du sikker på at du vil slette brukeren {{username}}? Denne handlingen kan ikke angres.", + "confirm_button": "Slett", + "cancel_button": "Avbryt" + }, + "edit_user_modal": { + "title": "Rediger bruker: {{username}}", + "username_label": "Brukernavn", + "email_label": "E-post", + "role_label": "Rolle", + "banned_label": "Utestengt", + "save_button": "Lagre", + "cancel_button": "Avbryt" + }, + "add_user_modal": { + "title": "Legg til ny bruker", + "name_label": "Navn", + "username_label": "Brukernavn", + "email_label": "E-post", + "password_label": "Passord", + "role_label": "Rolle", + "save_button": "Legg til bruker", + "cancel_button": "Avbryt" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Tilbake til innlogging", + "check_email_title": "Sjekk e-posten din", + "check_email_description": "Vi har sendt en lenke for tilbakestilling av passord til {{email}}", + "did_not_receive_email": "Mottok du ikke e-posten? Sjekk søppelpostmappen eller prøv igjen.", + "try_again_button": "Prøv igjen", + "forgot_password_title": "Glemt passord?", + "forgot_password_description": "Ingen bekymringer, vi sender deg instruksjoner for tilbakestilling", + "email_label": "E-post", + "email_placeholder": "Skriv inn e-posten din", + "email_hint": "Skriv inn e-posten knyttet til kontoen din", + "sending_button": "Sender...", + "reset_password_button": "Tilbakestill passord", + "remember_password": "Husker du passordet ditt?", + "sign_in_link": "Logg inn", + "unexpected_error": "En uventet feil oppstod. Vennligst prøv igjen." + }, + "login_page": { + "back_to_hemmelig": "Tilbake til Hemmelig", + "welcome_back": "Velkommen tilbake til Hemmelig", + "welcome_back_title": "Velkommen tilbake", + "welcome_back_description": "Logg inn på din Hemmelig-konto", + "username_label": "Brukernavn", + "username_placeholder": "Skriv inn brukernavnet ditt", + "password_label": "Passord", + "password_placeholder": "Skriv inn passordet ditt", + "forgot_password_link": "Glemt passordet?", + "signing_in_button": "Logger inn...", + "sign_in_button": "Logg inn", + "or_continue_with": "Eller fortsett med", + "continue_with_github": "Fortsett med GitHub", + "no_account_question": "Har du ikke en konto?", + "sign_up_link": "Registrer deg", + "unexpected_error": "En uventet feil oppstod. Vennligst prøv igjen." + }, + "register_page": { + "back_to_hemmelig": "Tilbake til Hemmelig", + "join_hemmelig": "Bli med i Hemmelig for å dele hemmeligheter sikkert", + "email_password_disabled_message": "Registrering med e-post og passord er deaktivert. Bruk en av de sosiale innloggingsalternativene nedenfor.", + "create_account_title": "Opprett konto", + "create_account_description": "Bli med i Hemmelig for å dele hemmeligheter sikkert", + "username_label": "Brukernavn", + "username_placeholder": "Velg et brukernavn", + "email_label": "E-post", + "email_placeholder": "Skriv inn e-posten din", + "password_label": "Passord", + "password_placeholder": "Opprett et passord", + "password_strength_label": "Passordstyrke", + "password_strength_levels": { + "very_weak": "Veldig svakt", + "weak": "Svakt", + "fair": "Middels", + "good": "Godt", + "strong": "Sterkt" + }, + "confirm_password_label": "Bekreft passord", + "confirm_password_placeholder": "Bekreft passordet ditt", + "passwords_match": "Passordene samsvarer", + "passwords_do_not_match": "Passordene samsvarer ikke", + "password_mismatch_alert": "Passordene samsvarer ikke", + "creating_account_button": "Oppretter konto...", + "create_account_button": "Opprett konto", + "or_continue_with": "Eller fortsett med", + "continue_with_github": "Fortsett med GitHub", + "already_have_account_question": "Har du allerede en konto?", + "sign_in_link": "Logg inn", + "invite_code_label": "Invitasjonskode", + "invite_code_placeholder": "Skriv inn invitasjonskoden din", + "invite_code_required": "Invitasjonskode er påkrevd", + "invalid_invite_code": "Ugyldig invitasjonskode", + "failed_to_validate_invite": "Kunne ikke validere invitasjonskode", + "unexpected_error": "En uventet feil oppstod. Vennligst prøv igjen.", + "email_domain_not_allowed": "E-postdomenet er ikke tillatt", + "account_already_exists": "En konto med denne e-posten eksisterer allerede. Vennligst logg inn i stedet." + }, + "secret_form": { + "failed_to_create_secret": "Kunne ikke opprette hemmelighet: {{errorMessage}}", + "failed_to_upload_file": "Kunne ikke laste opp fil: {{fileName}}" + }, + "secret_page": { + "password_label": "Passord", + "password_placeholder": "Skriv inn passord for å se hemmelighet", + "decryption_key_label": "Dekrypteringsnøkkel", + "decryption_key_placeholder": "Skriv inn dekrypteringsnøkkelen", + "view_secret_button": "Se hemmelighet", + "views_remaining_tooltip": "Gjenstående visninger: {{count}}", + "loading_message": "Dekrypterer hemmelighet...", + "files_title": "Vedlagte filer", + "secret_waiting_title": "Noen delte en hemmelighet med deg", + "secret_waiting_description": "Denne hemmeligheten er kryptert og kan kun vises når du klikker på knappen nedenfor.", + "one_view_remaining": "Denne hemmeligheten kan kun vises 1 gang til", + "views_remaining": "Denne hemmeligheten kan vises {{count}} ganger til", + "view_warning": "Når den er vist, kan denne handlingen ikke angres", + "secret_revealed": "Hemmelighet", + "copy_secret": "Kopier til utklippstavle", + "download": "Last ned", + "create_your_own": "Opprett din egen hemmelighet", + "encrypted_secret": "Kryptert hemmelighet", + "unlock_secret": "Lås opp hemmelighet", + "delete_secret": "Slett hemmelighet", + "delete_modal_title": "Slett hemmelighet", + "delete_modal_message": "Er du sikker på at du vil slette denne hemmeligheten? Denne handlingen kan ikke angres.", + "decryption_failed": "Kunne ikke dekryptere hemmeligheten. Vennligst sjekk passordet eller dekrypteringsnøkkelen din.", + "fetch_error": "En feil oppstod under henting av hemmeligheten. Vennligst prøv igjen." + }, + "expiration": { + "28_days": "28 dager", + "14_days": "14 dager", + "7_days": "7 dager", + "3_days": "3 dager", + "1_day": "1 dag", + "12_hours": "12 timer", + "4_hours": "4 timer", + "1_hour": "1 time", + "30_minutes": "30 minutter", + "5_minutes": "5 minutter" + }, + "error_display": { + "clear_errors_button_title": "Fjern feil" + }, + "secret_not_found_page": { + "title": "Hemmelighet ikke funnet", + "message": "Hemmeligheten du ser etter eksisterer ikke, har utløpt, eller har blitt brent.", + "error_details": "Feildetaljer:", + "go_home_button": "Gå til hjemmesiden" + }, + "organization_page": { + "title": "Organisasjonsinnstillinger", + "description": "Konfigurer organisasjonsomfattende innstillinger og tilgangskontroller", + "registration_settings": { + "title": "Registreringsinnstillinger", + "description": "Kontroller hvordan brukere kan bli med i organisasjonen din", + "invite_only_title": "Kun invitasjonsregistrering", + "invite_only_description": "Brukere kan kun registrere seg med en gyldig invitasjonskode", + "require_registered_user_title": "Kun registrerte brukere", + "require_registered_user_description": "Kun registrerte brukere kan opprette hemmeligheter", + "disable_email_password_signup_title": "Deaktiver e-post/passord registrering", + "disable_email_password_signup_description": "Deaktiver registrering med e-post og passord (kun sosial innlogging)", + "allowed_domains_title": "Tillatte e-postdomener", + "allowed_domains_description": "Tillat kun registrering fra spesifikke e-postdomener (komseparert, f.eks., bedrift.com, org.net)", + "allowed_domains_placeholder": "bedrift.com, org.net", + "allowed_domains_hint": "Komseparert liste over e-postdomener. La stå tomt for å tillate alle domener." + }, + "invite_codes": { + "title": "Invitasjonskoder", + "description": "Opprett og administrer invitasjonskoder for nye brukere", + "create_invite_button": "Opprett invitasjonskode", + "code_header": "Kode", + "uses_header": "Bruk", + "expires_header": "Utløper", + "actions_header": "Handlinger", + "unlimited": "Ubegrenset", + "never": "Aldri", + "expired": "Utløpt", + "no_invites": "Ingen invitasjonskoder ennå", + "no_invites_description": "Opprett en invitasjonskode for å tillate nye brukere å registrere seg", + "copy_tooltip": "Kopier kode", + "delete_tooltip": "Slett kode" + }, + "create_invite_modal": { + "title": "Opprett invitasjonskode", + "max_uses_label": "Maks bruk", + "max_uses_placeholder": "La stå tomt for ubegrenset", + "expiration_label": "Utløpsdato", + "expiration_options": { + "never": "Aldri", + "24_hours": "24 timer", + "7_days": "7 dager", + "30_days": "30 dager" + }, + "cancel_button": "Avbryt", + "create_button": "Opprett" + }, + "saving_button": "Lagrer...", + "save_settings_button": "Lagre innstillinger" + }, + "invites_page": { + "title": "Invitasjonskoder", + "description": "Administrer invitasjonskoder for nye brukerregistreringer", + "create_invite_button": "Opprett invitasjon", + "loading": "Laster invitasjonskoder...", + "table": { + "code_header": "Kode", + "uses_header": "Bruk", + "expires_header": "Utløper", + "status_header": "Status", + "never": "Aldri" + }, + "status": { + "active": "Aktiv", + "expired": "Utløpt", + "used": "Brukt", + "inactive": "Inaktiv" + }, + "no_invites": "Ingen invitasjonskoder ennå", + "create_modal": { + "title": "Opprett invitasjonskode", + "max_uses_label": "Maksimal bruk", + "expires_in_label": "Utløper om (dager)" + }, + "delete_modal": { + "title": "Deaktiver invitasjonskode", + "confirm_text": "Deaktiver", + "cancel_text": "Avbryt", + "message": "Er du sikker på at du vil deaktivere invitasjonskode {{code}}? Denne handlingen kan ikke angres." + }, + "toast": { + "created": "Invitasjonskode opprettet", + "deactivated": "Invitasjonskode deaktivert", + "copied": "Invitasjonskode kopiert til utklippstavle", + "fetch_error": "Kunne ikke hente invitasjonskoder", + "create_error": "Kunne ikke opprette invitasjonskode", + "delete_error": "Kunne ikke deaktivere invitasjonskode" + } + }, + "social_login": { + "continue_with": "Fortsett med {{provider}}", + "sign_up_with": "Registrer deg med {{provider}}" + }, + "setup_page": { + "title": "Velkommen til Hemmelig", + "description": "Opprett din administratorkonto for å komme i gang", + "name_label": "Fullt navn", + "name_placeholder": "Skriv inn fullt navn", + "username_label": "Brukernavn", + "username_placeholder": "Velg et brukernavn", + "email_label": "E-postadresse", + "email_placeholder": "Skriv inn e-posten din", + "password_label": "Passord", + "password_placeholder": "Opprett et passord (min 8 tegn)", + "confirm_password_label": "Bekreft passord", + "confirm_password_placeholder": "Bekreft passordet ditt", + "create_admin": "Opprett administratorkonto", + "creating": "Oppretter konto...", + "success": "Administratorkonto opprettet! Vennligst logg inn.", + "error": "Kunne ikke opprette administratorkonto", + "passwords_mismatch": "Passordene samsvarer ikke", + "password_too_short": "Passordet må være på minst 8 tegn", + "note": "Dette oppsettet kan kun fullføres én gang. Administratorkontoen vil ha full tilgang til å administrere denne instansen." + }, + "theme_toggle": { + "switch_to_light": "Bytt til lys modus", + "switch_to_dark": "Bytt til mørk modus" + }, + "webhook_settings": { + "title": "Webhook-varsler", + "description": "Varsle eksterne tjenester når hemmeligheter vises eller brennes", + "enable_webhooks_title": "Aktiver webhooks", + "enable_webhooks_description": "Send HTTP POST-forespørsler til din webhook-URL når hendelser oppstår", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://eksempel.no/webhook", + "webhook_url_hint": "URL-en hvor webhook-nyttelaster vil bli sendt", + "webhook_secret_label": "Webhook-hemmelighet", + "webhook_secret_placeholder": "Skriv inn en hemmelighet for HMAC-signering", + "webhook_secret_hint": "Brukes til å signere webhook-nyttelaster med HMAC-SHA256. Signaturen sendes i X-Hemmelig-Signature-overskriften.", + "events_title": "Webhook-hendelser", + "on_view_title": "Hemmelighet vist", + "on_view_description": "Send en webhook når en hemmelighet vises", + "on_burn_title": "Hemmelighet brent", + "on_burn_description": "Send en webhook når en hemmelighet brennes eller slettes" + }, + "metrics_settings": { + "title": "Prometheus-metrikk", + "description": "Eksponer metrikk for overvåking med Prometheus", + "enable_metrics_title": "Aktiver Prometheus-metrikk", + "enable_metrics_description": "Eksponer et /api/metrics endepunkt for Prometheus-skraping", + "metrics_secret_label": "Metrikk-hemmelighet", + "metrics_secret_placeholder": "Skriv inn en hemmelighet for autentisering", + "metrics_secret_hint": "Brukes som et Bearer-token for å autentisere forespørsler til metrikk-endepunktet. La stå tomt for ingen autentisering (ikke anbefalt).", + "endpoint_info_title": "Endepunktsinformasjon", + "endpoint_info_description": "Når aktivert, vil metrikk være tilgjengelig på:", + "endpoint_auth_hint": "Inkluder hemmeligheten som et Bearer-token i Authorization-overskriften når du henter metrikk." + }, + "verify_2fa_page": { + "back_to_login": "Tilbake til innlogging", + "title": "Tofaktorautentisering", + "description": "Skriv inn den 6-sifrede koden fra autentiseringsappen din", + "enter_code_hint": "Skriv inn koden fra autentiseringsappen", + "verifying": "Bekrefter...", + "verify_button": "Bekreft", + "invalid_code": "Ugyldig bekreftelseskode. Vennligst prøv igjen.", + "unexpected_error": "En uventet feil oppstod. Vennligst prøv igjen." + }, + "common": { + "error": "Feil", + "cancel": "Avbryt", + "confirm": "Bekreft", + "ok": "OK", + "delete": "Slett", + "deleting": "Sletter...", + "loading": "Laster..." + }, + "pagination": { + "showing": "Viser {{start}} til {{end}} av {{total}} resultater", + "previous_page": "Forrige side", + "next_page": "Neste side" + }, + "not_found_page": { + "title": "Side ikke funnet", + "message": "Denne siden har forsvunnet i løse luften, akkurat som våre hemmeligheter gjør.", + "hint": "Siden du leter etter eksisterer ikke eller har blitt flyttet.", + "go_home_button": "Gå hjem", + "create_secret_button": "Opprett hemmelighet" + }, + "error_boundary": { + "title": "Noe gikk galt", + "message": "En uventet feil oppstod under behandling av forespørselen din.", + "hint": "Ikke bekymre deg, hemmelighetene dine er fortsatt trygge. Prøv å oppdatere siden.", + "error_details": "Feildetaljer:", + "unknown_error": "En ukjent feil oppstod", + "try_again_button": "Prøv igjen", + "go_home_button": "Gå hjem" + }, + "secret_requests_page": { + "title": "Hemmeligforespørsler", + "description": "Be om hemmeligheter fra andre via sikre lenker", + "create_request_button": "Opprett forespørsel", + "no_requests": "Ingen hemmeligforespørsler ennå. Opprett en for å komme i gang.", + "table": { + "title_header": "Tittel", + "status_header": "Status", + "secret_expiry_header": "Hemmelighet utløper", + "link_expires_header": "Lenke utløper", + "copy_link_tooltip": "Kopier oppretter-lenke", + "view_secret_tooltip": "Vis hemmelighet", + "cancel_tooltip": "Avbryt forespørsel" + }, + "status": { + "pending": "Venter", + "fulfilled": "Oppfylt", + "expired": "Utløpt", + "cancelled": "Avbrutt" + }, + "time": { + "days": "{{count}} dag", + "days_plural": "{{count}} dager", + "hours": "{{count}} time", + "hours_plural": "{{count}} timer", + "minutes": "{{count}} minutt", + "minutes_plural": "{{count}} minutter" + }, + "link_modal": { + "title": "Oppretter-lenke", + "description": "Send denne lenken til personen som skal levere hemmeligheten. De kan skrive inn og kryptere hemmeligheten via denne lenken.", + "copy_button": "Kopier lenke", + "close_button": "Lukk", + "warning": "Denne lenken kan kun brukes én gang. Når en hemmelighet er sendt inn, vil lenken ikke lenger fungere." + }, + "cancel_modal": { + "title": "Avbryt forespørsel", + "message": "Er du sikker på at du vil avbryte forespørselen \"{{title}}\"? Denne handlingen kan ikke angres.", + "confirm_text": "Avbryt forespørsel", + "cancel_text": "Behold forespørsel" + }, + "toast": { + "copied": "Lenke kopiert til utklippstavle", + "cancelled": "Forespørsel avbrutt", + "fetch_error": "Kunne ikke hente forespørselsdetaljer", + "cancel_error": "Kunne ikke avbryte forespørsel" + } + }, + "create_request_page": { + "title": "Opprett hemmeligforespørsel", + "description": "Be om en hemmelighet fra noen ved å generere en sikker lenke de kan bruke til å sende den inn", + "back_button": "Tilbake til hemmeligforespørsler", + "form": { + "title_label": "Forespørselstittel", + "title_placeholder": "f.eks. AWS-legitimasjon for Prosjekt X", + "description_label": "Beskrivelse (valgfritt)", + "description_placeholder": "Gi ytterligere kontekst om hva du trenger...", + "link_validity_label": "Lenkegyldighet", + "link_validity_hint": "Hvor lenge oppretter-lenken forblir aktiv", + "secret_settings_title": "Hemmeliginnstillinger", + "secret_settings_description": "Disse innstillingene vil gjelde for hemmeligheten når den er opprettet", + "secret_expiration_label": "Hemmelighet utløper", + "max_views_label": "Maksimale visninger", + "password_label": "Passordbeskyttelse (valgfritt)", + "password_placeholder": "Skriv inn et passord (min 5 tegn)", + "password_hint": "Mottakere trenger dette passordet for å se hemmeligheten", + "ip_restriction_label": "IP-begrensning (valgfritt)", + "ip_restriction_placeholder": "192.168.1.0/24 eller 203.0.113.5", + "prevent_burn_label": "Forhindre auto-sletting (behold hemmelighet etter maks visninger)", + "webhook_title": "Webhook-varsling (valgfritt)", + "webhook_description": "Motta en varsling når hemmeligheten sendes inn", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://din-server.com/webhook", + "webhook_url_hint": "HTTPS anbefalt. En varsling sendes når hemmeligheten opprettes.", + "creating_button": "Oppretter...", + "create_button": "Opprett forespørsel" + }, + "validity": { + "30_days": "30 dager", + "14_days": "14 dager", + "7_days": "7 dager", + "3_days": "3 dager", + "1_day": "1 dag", + "12_hours": "12 timer", + "1_hour": "1 time" + }, + "success": { + "title": "Forespørsel opprettet!", + "description": "Del oppretter-lenken med personen som skal levere hemmeligheten", + "creator_link_label": "Oppretter-lenke", + "webhook_secret_label": "Webhook-hemmelighet", + "webhook_secret_warning": "Lagre denne hemmeligheten nå! Den vises ikke igjen. Bruk den til å verifisere webhook-signaturer.", + "expires_at": "Lenke utløper: {{date}}", + "create_another_button": "Opprett en ny forespørsel", + "view_all_button": "Se alle forespørsler" + }, + "toast": { + "created": "Hemmeligforespørsel opprettet", + "create_error": "Kunne ikke opprette hemmeligforespørsel", + "copied": "Kopiert til utklippstavle" + } + }, + "request_secret_page": { + "loading": "Laster forespørsel...", + "error": { + "title": "Forespørsel ikke tilgjengelig", + "invalid_link": "Denne lenken er ugyldig eller har blitt endret.", + "not_found": "Denne forespørselen ble ikke funnet, eller lenken er ugyldig.", + "already_fulfilled": "Denne forespørselen er allerede oppfylt eller utløpt.", + "generic": "Det oppstod en feil under lasting av forespørselen.", + "go_home_button": "Gå til forsiden" + }, + "form": { + "title": "Send inn en hemmelighet", + "description": "Noen har bedt deg om å dele en hemmelighet med dem på en sikker måte", + "password_protected_note": "Denne hemmeligheten vil være passordbeskyttet", + "encryption_note": "Hemmeligheten din krypteres i nettleseren før den sendes. Dekrypteringsnøkkelen inkluderes kun i den endelige URL-en du deler.", + "submitting_button": "Krypterer og sender inn...", + "submit_button": "Send inn hemmelighet" + }, + "success": { + "title": "Hemmelighet opprettet!", + "description": "Hemmeligheten din er kryptert og lagret sikkert", + "decryption_key_label": "Dekrypteringsnøkkel", + "warning": "Viktig: Kopier denne dekrypteringsnøkkelen nå og send den til forespørreren. Dette er den eneste gangen du vil se den!", + "manual_send_note": "Du må manuelt sende denne dekrypteringsnøkkelen til personen som ba om hemmeligheten. De har allerede hemmelighetens URL i sitt dashboard.", + "create_own_button": "Opprett din egen hemmelighet" + }, + "toast": { + "created": "Hemmelighet sendt inn", + "create_error": "Kunne ikke sende inn hemmelighet", + "copied": "Kopiert til utklippstavle" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/sv/sv.json b/src/i18n/locales/sv/sv.json new file mode 100644 index 0000000..5bd2591 --- /dev/null +++ b/src/i18n/locales/sv/sv.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "Mallar", + "description": "Snabbstarta med en mall", + "templates": { + "credentials": "Inloggningsuppgifter", + "api_key": "API-nyckel", + "database": "Databas", + "server": "Serveråtkomst", + "credit_card": "Betalkort", + "email": "E-postkonto" + } + }, + "editor": { + "tooltips": { + "copy_text": "Kopiera som vanlig text", + "copy_html": "Kopiera som HTML", + "copy_base64": "Kopiera som Base64", + "bold": "Fet", + "italic": "Kursiv", + "strikethrough": "Genomstruken", + "inline_code": "Inbäddad kod", + "link": "Länk", + "remove_link": "Ta bort länk", + "insert_password": "Infoga lösenord", + "paragraph": "Stycke", + "heading1": "Rubrik 1", + "heading2": "Rubrik 2", + "heading3": "Rubrik 3", + "bullet_list": "Punktlista", + "numbered_list": "Numrerad lista", + "blockquote": "Citatblock", + "code_block": "Kodblock", + "undo": "Ångra", + "redo": "Gör om" + }, + "copy_success": { + "html": "HTML kopierad!", + "text": "Text kopierad!", + "base64": "Base64 kopierad!" + }, + "link_modal": { + "title": "Lägg till länk", + "url_label": "URL", + "url_placeholder": "Ange URL", + "cancel": "Avbryt", + "update": "Uppdatera", + "insert": "Infoga" + }, + "password_modal": { + "title": "Generera lösenord", + "length_label": "Lösenordslängd", + "options_label": "Alternativ", + "include_numbers": "Siffror", + "include_symbols": "Symboler", + "include_uppercase": "Versaler", + "include_lowercase": "Gemener", + "generated_password": "Genererat lösenord", + "refresh": "Uppdatera", + "cancel": "Avbryt", + "insert": "Infoga", + "copied_and_added": "Lösenord tillagt och kopierat till urklipp", + "added": "Lösenord tillagt" + }, + "formatting_tools": "Formateringsverktyg", + "character_count": "tecken" + }, + "create_button": { + "creating_secret": "Skapar hemlighet...", + "create": "Skapa" + }, + "file_upload": { + "sign_in_to_upload": "Logga in för att ladda upp filer", + "sign_in": "Logga in", + "drop_files_here": "Släpp filer här", + "drag_and_drop": "Dra och släpp en fil, eller klicka för att välja", + "uploading": "Laddar upp...", + "upload_file": "Ladda upp fil", + "file_too_large": "Filen \"{{fileName}}\" ({{fileSize}} MB) överskrider maximal storlek på {{maxSize}} MB", + "max_size_exceeded": "Total filstorlek överskrider maximum på {{maxSize}} MB" + }, + "footer": { + "tagline": "Hemmelig, [heˈm(ɛ)li], betyder 'secret' på norska", + "privacy": "Integritet", + "terms": "Villkor", + "api": "API", + "managed_hosting": "Managed Hosting", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "Hem", + "sign_in": "Logga in", + "sign_up": "Registrera dig", + "dashboard": "Översikt", + "hero_text_part1": "Dela hemligheter säkert med krypterade meddelanden som automatiskt", + "hero_text_part2": " självförstörs", + "hero_text_part3": " efter att ha lästs." + }, + "dashboard_layout": { + "secrets": "Hemligheter", + "secret_requests": "Hemlighetsförfrågningar", + "account": "Konto", + "analytics": "Analys", + "users": "Användare", + "invites": "Inbjudningar", + "instance": "Instans", + "sign_out": "Logga ut", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "Hemlighet skapad!", + "secret_created_description": "Din hemlighet är nu tillgänglig på följande URL. Spara din dekrypteringsnyckel säkert, eftersom den inte kan återställas.", + "secret_url_label": "Hemlig URL", + "decryption_key_label": "Dekrypteringsnyckel", + "password_label": "Lösenord", + "create_new_secret_button": "Skapa ny hemlighet", + "copy_url_button": "Kopiera URL", + "burn_secret_button": "Bränn hemlighet", + "max_secrets_per_user_info": "Du kan skapa upp till {{count}} hemligheter.", + "failed_to_burn": "Misslyckades med att bränna hemligheten. Försök igen." + }, + "security_settings": { + "security_title": "Säkerhet", + "security_description": "Konfigurera säkerhetsinställningar för din hemlighet", + "remember_settings": "Kom ihåg", + "private_title": "Privat", + "private_description": "Privata hemligheter är krypterade och kan endast visas med dekrypteringsnyckeln och/eller lösenord.", + "expiration_title": "Utgångsdatum", + "expiration_burn_after_time_description": "Ställ in när hemligheten ska förstöras", + "expiration_default_description": "Ställ in hur länge hemligheten ska vara tillgänglig", + "max_views_title": "Max visningar", + "burn_after_time_mode_title": "Bränn efter tid-läge", + "burn_after_time_mode_description": "Hemligheten kommer att förstöras efter att tiden går ut, oavsett hur många gånger den visas.", + "password_protection_title": "Lösenordsskydd", + "password_protection_description": "Lägg till ett extra säkerhetslager med ett lösenord", + "enter_password_label": "Ange lösenord", + "password_placeholder": "Ange ett säkert lösenord...", + "password_hint": "Minst 5 tecken. Mottagare behöver detta lösenord för att se hemligheten", + "password_error": "Lösenordet måste vara minst 5 tecken", + "ip_restriction_title": "Begränsa efter IP eller CIDR", + "ip_restriction_description": "CIDR-inmatning låter användare ange IP-adressintervall som kan komma åt hemligheten.", + "ip_address_cidr_label": "IP-adress eller CIDR-intervall", + "ip_address_cidr_placeholder": "192.168.1.0/24 eller 203.0.113.5", + "ip_address_cidr_hint": "Endast förfrågningar från dessa IP-adresser kommer att kunna komma åt hemligheten", + "burn_after_time_title": "Bränn efter att tiden går ut", + "burn_after_time_description": "Bränn hemligheten endast efter att tiden går ut" + }, + "title_field": { + "placeholder": "Titel", + "hint": "Ge din hemlighet en titel (valfritt)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "visningar" + }, + "account_page": { + "title": "Kontoinställningar", + "description": "Hantera dina kontopreferenser och säkerhet", + "tabs": { + "profile": "Profil", + "security": "Säkerhet", + "developer": "Utvecklare", + "danger_zone": "Farozon" + }, + "profile_info": { + "title": "Profilinformation", + "description": "Uppdatera din personliga information", + "first_name_label": "Förnamn", + "last_name_label": "Efternamn", + "username_label": "Användarnamn", + "email_label": "E-postadress", + "saving_button": "Sparar...", + "save_changes_button": "Spara ändringar" + }, + "profile_settings": { + "username_taken": "Användarnamnet är redan upptaget" + }, + "security_settings": { + "title": "Säkerhetsinställningar", + "description": "Hantera ditt lösenord och säkerhetspreferenser", + "change_password_title": "Byt lösenord", + "current_password_label": "Nuvarande lösenord", + "current_password_placeholder": "Ange nuvarande lösenord", + "new_password_label": "Nytt lösenord", + "new_password_placeholder": "Ange nytt lösenord", + "confirm_new_password_label": "Bekräfta nytt lösenord", + "confirm_new_password_placeholder": "Bekräfta nytt lösenord", + "password_mismatch_alert": "Nya lösenord matchar inte", + "changing_password_button": "Ändrar...", + "change_password_button": "Byt lösenord", + "password_change_success": "Lösenordet ändrades!", + "password_change_error": "Misslyckades med att ändra lösenord. Försök igen." + }, + "two_factor": { + "title": "Tvåfaktorsautentisering", + "description": "Lägg till ett extra säkerhetslager på ditt konto", + "enabled": "Aktiverat", + "disabled": "Ej aktiverat", + "setup_button": "Konfigurera 2FA", + "disable_button": "Inaktivera 2FA", + "enter_password_to_enable": "Ange ditt lösenord för att aktivera tvåfaktorsautentisering.", + "continue": "Fortsätt", + "scan_qr_code": "Skanna denna QR-kod med din autentiseringsapp (Google Authenticator, Authy, etc.).", + "manual_entry_hint": "Eller ange denna kod manuellt i din autentiseringsapp:", + "enter_verification_code": "Ange den 6-siffriga koden från din autentiseringsapp för att verifiera installationen.", + "verification_code": "Verifieringskod", + "verify_and_enable": "Verifiera & Aktivera", + "back": "Tillbaka", + "disable_title": "Inaktivera tvåfaktorsautentisering", + "disable_warning": "Inaktivering av 2FA gör ditt konto mindre säkert. Du måste ange ditt lösenord för att bekräfta.", + "invalid_password": "Ogiltigt lösenord. Försök igen.", + "invalid_code": "Ogiltig verifieringskod. Försök igen.", + "enable_error": "Misslyckades med att aktivera 2FA. Försök igen.", + "verify_error": "Misslyckades med att verifiera 2FA-kod. Försök igen.", + "disable_error": "Misslyckades med att inaktivera 2FA. Försök igen.", + "backup_codes_title": "Säkerhetskoder", + "backup_codes_description": "Spara dessa säkerhetskoder på ett säkert ställe. Du kan använda dem för att komma åt ditt konto om du förlorar åtkomsten till din autentiseringsapp.", + "backup_codes_warning": "Varje kod kan endast användas en gång. Spara dem säkert!", + "backup_codes_saved": "Jag har sparat mina säkerhetskoder" + }, + "danger_zone": { + "title": "Farozon", + "description": "Oåterkalleliga och destruktiva åtgärder", + "delete_account_title": "Ta bort konto", + "delete_account_description": "När du tar bort ditt konto finns det ingen återvändo. Detta kommer permanent att radera ditt konto, alla dina hemligheter och ta bort all tillhörande data. Denna åtgärd kan inte ångras.", + "delete_account_bullet1": "Alla dina hemligheter kommer att raderas permanent", + "delete_account_bullet2": "Dina kontodata kommer att tas bort från våra servrar", + "delete_account_bullet3": "Alla delade hemlighetslänkar blir ogiltiga", + "delete_account_bullet4": "Denna åtgärd kan inte ångras", + "delete_account_confirm": "Är du säker på att du vill ta bort ditt konto? Denna åtgärd kan inte ångras.", + "delete_account_button": "Ta bort konto", + "deleting_account_button": "Tar bort konto..." + }, + "developer": { + "title": "API-nycklar", + "description": "Hantera API-nycklar för programmatisk åtkomst", + "create_key": "Skapa nyckel", + "create_key_title": "Skapa API-nyckel", + "key_name": "Nyckelnamn", + "key_name_placeholder": "t.ex., Min integration", + "expiration": "Utgångsdatum", + "never_expires": "Går aldrig ut", + "expires_30_days": "30 dagar", + "expires_90_days": "90 dagar", + "expires_1_year": "1 år", + "create_button": "Skapa", + "name_required": "Nyckelnamn krävs", + "create_error": "Misslyckades med att skapa API-nyckel", + "key_created": "API-nyckel skapad!", + "key_warning": "Kopiera denna nyckel nu. Du kommer inte att kunna se den igen.", + "dismiss": "Jag har kopierat nyckeln", + "no_keys": "Inga API-nycklar än. Skapa en för att komma igång.", + "created": "Skapad", + "last_used": "Senast använd", + "expires": "Går ut", + "docs_hint": "Lär dig hur du använder API-nycklar i", + "api_docs": "API-dokumentationen" + } + }, + "analytics_page": { + "title": "Analys", + "description": "Spåra din hemlighetsdelningsaktivitet och insikter", + "time_range": { + "last_7_days": "Senaste 7 dagarna", + "last_14_days": "Senaste 14 dagarna", + "last_30_days": "Senaste 30 dagarna" + }, + "total_secrets": "Totalt antal hemligheter", + "from_last_period": "+{{percentage}}% från föregående period", + "total_views": "Totalt antal visningar", + "avg_views_per_secret": "Genomsnitt visningar/hemlighet", + "active_secrets": "Aktiva hemligheter", + "daily_activity": { + "title": "Daglig aktivitet", + "description": "Hemligheter skapade och visningar över tid", + "secrets": "Hemligheter", + "views": "Visningar", + "secrets_created": "Hemligheter skapade", + "secret_views": "Hemlighetsvisningar", + "date": "Datum", + "trend": "Förändring", + "vs_previous": "vs föregående dag", + "no_data": "Ingen aktivitetsdata tillgänglig än." + }, + "locale": "sv-SE", + "top_countries": { + "title": "Toppländer", + "description": "Var dina hemligheter visas", + "views": "visningar" + }, + "secret_types": { + "title": "Hemlighetstyper", + "description": "Fördelning efter skyddsnivå", + "password_protected": "Lösenordsskyddad", + "ip_restricted": "IP-begränsad", + "burn_after_time": "Bränn efter tid" + }, + "expiration_stats": { + "title": "Utgångsstatistik", + "description": "Hur länge hemligheter vanligtvis varar", + "one_hour": "1 timme", + "one_day": "1 dag", + "one_week_plus": "1 vecka+" + }, + "visitor_analytics": { + "title": "Besöksanalys", + "description": "Sidvisningar och unika besökare", + "unique": "Unika", + "views": "Visningar", + "date": "Datum", + "trend": "Förändring", + "vs_previous": "vs föregående dag", + "no_data": "Ingen besöksdata tillgänglig än." + }, + "secret_requests": { + "total": "Hemlighetsförfrågningar", + "fulfilled": "Uppfyllda förfrågningar" + }, + "loading": "Laddar analys...", + "no_permission": "Du har inte behörighet att se analys.", + "failed_to_fetch": "Misslyckades med att hämta analysdata." + }, + "instance_page": { + "title": "Instansinställningar", + "description": "Konfigurera din Hemmelig-instans", + "managed_mode": { + "title": "Hanterat läge", + "description": "Denna instans hanteras via miljövariabler. Inställningarna är skrivskyddade." + }, + "tabs": { + "general": "Allmänt", + "security": "Säkerhet", + "organization": "Organisation", + "webhook": "Webhooks", + "metrics": "Mätvärden" + }, + "system_status": { + "title": "Systemstatus", + "description": "Instanshälsa och prestandamätvärden", + "version": "Version", + "uptime": "Upptid", + "memory": "Minne", + "cpu_usage": "CPU-användning" + }, + "general_settings": { + "title": "Allmänna inställningar", + "description": "Grundläggande instanskonfiguration", + "instance_name_label": "Instansnamn", + "logo_label": "Instanslogotyp", + "logo_upload": "Ladda upp logotyp", + "logo_remove": "Ta bort logotyp", + "logo_hint": "PNG, JPEG, GIF, SVG eller WebP. Max 512KB.", + "logo_alt": "Instanslogotyp", + "logo_invalid_type": "Ogiltig filtyp. Ladda upp en PNG-, JPEG-, GIF-, SVG- eller WebP-bild.", + "logo_too_large": "Filen är för stor. Maximal storlek är 512KB.", + "default_expiration_label": "Standard hemlighetsutgång", + "max_secrets_per_user_label": "Max hemligheter per användare", + "max_secret_size_label": "Max hemlighetsstorlek (MB)", + "instance_description_label": "Instansbeskrivning", + "important_message_label": "Viktigt meddelande", + "important_message_placeholder": "Ange ett viktigt meddelande som ska visas för alla användare...", + "important_message_hint": "Dette meddelande kommer att visas som en varningsbanner på hemsidan. Stödjer markdown-formatering. Lämna tomt för att dölja.", + "allow_registration_title": "Tillåt registrering", + "allow_registration_description": "Tillåt nya användare att registrera sig", + "email_verification_title": "E-postverifiering", + "email_verification_description": "Kräv e-postverifiering" + }, + "saving_button": "Sparar...", + "save_settings_button": "Spara inställningar", + "security_settings": { + "title": "Säkerhetsinställningar", + "description": "Konfigurera säkerhet och åtkomstkontroller", + "rate_limiting_title": "Hastighetsbegränsning", + "rate_limiting_description": "Aktivera begäran om hastighetsbegränsning", + "max_password_attempts_label": "Max lösenordsförsök", + "session_timeout_label": "Sessionstimeout (timmar)", + "allow_file_uploads_title": "Tillåt filuppladdningar", + "allow_file_uploads_description": "Tillåt användare att bifoga filer till hemligheter" + }, + "email_settings": { + "title": "E-postinställningar", + "description": "Konfigurera SMTP och e-postaviseringar", + "smtp_host_label": "SMTP-värd", + "smtp_port_label": "SMTP-port", + "username_label": "Användarnamn", + "password_label": "Lösenord" + }, + "database_info": { + "title": "Databasinformation", + "description": "Databasstatus och statistik", + "stats_title": "Databasstatistik", + "total_secrets": "Totalt antal hemligheter:", + "total_users": "Totalt antal användare:", + "disk_usage": "Diskanvändning:", + "connection_status_title": "Anslutningsstatus", + "connected": "Ansluten", + "connected_description": "Databasen är frisk och svarar normalt" + }, + "system_info": { + "title": "Systeminformation", + "description": "Serverdetaljer och underhåll", + "system_info_title": "Systeminfo", + "version": "Version:", + "uptime": "Upptid:", + "status": "Status:", + "resource_usage_title": "Resursanvändning", + "memory": "Minne:", + "cpu": "CPU:", + "disk": "Disk:" + }, + "maintenance_actions": { + "title": "Underhållsåtgärder", + "description": "Dessa åtgärder kan påverka systemets tillgänglighet. Använd med försiktighet.", + "restart_service_button": "Starta om tjänsten", + "clear_cache_button": "Rensa cache", + "export_logs_button": "Exportera loggar" + } + }, + "secrets_page": { + "title": "Dina hemligheter", + "description": "Hantera och övervaka dina delade hemligheter", + "create_secret_button": "Skapa hemlighet", + "search_placeholder": "Sök hemligheter...", + "filter": { + "all_secrets": "Alla hemligheter", + "active": "Aktiva", + "expired": "Utgångna" + }, + "total_secrets": "Totalt antal hemligheter", + "active_secrets": "Aktiva", + "expired_secrets": "Utgångna", + "no_secrets_found_title": "Inga hemligheter hittades", + "no_secrets_found_description_filter": "Försök att justera dina sök- eller filterkriterier.", + "no_secrets_found_description_empty": "Skapa din första hemlighet för att komma igång.", + "password_protected": "Lösenord", + "files": "filer", + "table": { + "secret_header": "Hemlighet", + "created_header": "Skapad", + "status_header": "Status", + "views_header": "Visningar", + "actions_header": "Åtgärder", + "untitled_secret": "Namnlös hemlighet", + "expired_status": "Utgången", + "active_status": "Aktiv", + "never_expires": "Går aldrig ut", + "expired_time": "Utgången", + "views_left": "visningar kvar", + "copy_url_tooltip": "Kopiera URL", + "open_secret_tooltip": "Öppna hemlighet", + "delete_secret_tooltip": "Ta bort hemlighet", + "delete_confirmation_title": "Är du säker?", + "delete_confirmation_text": "Denna åtgärd kan inte ångras. Detta kommer permanent att radera hemligheten.", + "delete_confirm_button": "Ja, ta bort den", + "delete_cancel_button": "Avbryt" + } + }, + "users_page": { + "title": "Användarhantering", + "description": "Hantera användare och deras behörigheter", + "add_user_button": "Lägg till användare", + "search_placeholder": "Sök efter användare...", + "filter": { + "all_roles": "Alla roller", + "admin": "Admin", + "user": "Användare", + "all_status": "Alla statusar", + "active": "Aktiv", + "suspended": "Avstängd", + "pending": "Väntande" + }, + "total_users": "Totalt antal användare", + "active_users": "Aktiva", + "admins": "Administratörer", + "pending_users": "Väntande", + "no_users_found_title": "Inga användare hittades", + "no_users_found_description_filter": "Försök att justera dina sök- eller filterkriterier.", + "no_users_found_description_empty": "Inga användare har lagts till än.", + "table": { + "user_header": "Användare", + "role_header": "Roll", + "status_header": "Status", + "activity_header": "Aktivitet", + "last_login_header": "Senaste inloggning", + "actions_header": "Åtgärder", + "created_at": "Skapad den" + }, + "status": { + "active": "Aktiv", + "banned": "Bannlyst" + }, + "delete_user_modal": { + "title": "Ta bort användare", + "confirmation_message": "Är du säker på att du vill ta bort användaren {{username}}? Denna åtgärd kan inte ångras.", + "confirm_button": "Ta bort", + "cancel_button": "Avbryt" + }, + "edit_user_modal": { + "title": "Redigera användare: {{username}}", + "username_label": "Användarnamn", + "email_label": "E-post", + "role_label": "Roll", + "banned_label": "Bannlyst", + "save_button": "Spara", + "cancel_button": "Avbryt" + }, + "add_user_modal": { + "title": "Lägg till ny användare", + "name_label": "Namn", + "username_label": "Användarnamn", + "email_label": "E-post", + "password_label": "Lösenord", + "role_label": "Roll", + "save_button": "Lägg till användare", + "cancel_button": "Avbryt" + } + }, + "forgot_password_page": { + "back_to_sign_in": "Tillbaka till inloggning", + "check_email_title": "Kolla din e-post", + "check_email_description": "Vi har skickat en länk för återställning av lösenord till {{email}}", + "did_not_receive_email": "Fick du inte e-postmeddelandet? Kolla din skräppostmapp eller försök igen.", + "try_again_button": "Försök igen", + "forgot_password_title": "Glömt lösenord?", + "forgot_password_description": "Inga problem, vi skickar instruktioner för återställning", + "email_label": "E-post", + "email_placeholder": "Ange din e-post", + "email_hint": "Ange e-posten kopplad till ditt konto", + "sending_button": "Skickar...", + "reset_password_button": "Återställ lösenord", + "remember_password": "Kommer du ihåg ditt lösenord?", + "sign_in_link": "Logga in", + "unexpected_error": "Ett oväntat fel inträffade. Försök igen." + }, + "login_page": { + "back_to_hemmelig": "Tillbaka till Hemmelig", + "welcome_back": "Välkommen tillbaka till Hemmelig", + "welcome_back_title": "Välkommen tillbaka", + "welcome_back_description": "Logga in på ditt Hemmelig-konto", + "username_label": "Användarnamn", + "username_placeholder": "Ange ditt användarnamn", + "password_label": "Lösenord", + "password_placeholder": "Ange ditt lösenord", + "forgot_password_link": "Glömt ditt lösenord?", + "signing_in_button": "Loggar in...", + "sign_in_button": "Logga in", + "or_continue_with": "Eller fortsätt med", + "continue_with_github": "Fortsätt med GitHub", + "no_account_question": "Har du inget konto?", + "sign_up_link": "Registrera dig", + "unexpected_error": "Ett oväntat fel inträffade. Försök igen." + }, + "register_page": { + "back_to_hemmelig": "Tillbaka till Hemmelig", + "join_hemmelig": "Gå med i Hemmelig för att dela hemligheter säkert", + "email_password_disabled_message": "Registrering med e-post och lösenord är inaktiverad. Använd ett av de sociala inloggningsalternativen nedan.", + "create_account_title": "Skapa konto", + "create_account_description": "Gå med i Hemmelig för att dela hemligheter säkert", + "username_label": "Användarnamn", + "username_placeholder": "Välj ett användarnamn", + "email_label": "E-post", + "email_placeholder": "Ange din e-post", + "password_label": "Lösenord", + "password_placeholder": "Skapa ett lösenord", + "password_strength_label": "Lösenordsstyrka", + "password_strength_levels": { + "very_weak": "Mycket svagt", + "weak": "Svagt", + "fair": "Medel", + "good": "Bra", + "strong": "Starkt" + }, + "confirm_password_label": "Bekräfta lösenord", + "confirm_password_placeholder": "Bekräfta ditt lösenord", + "passwords_match": "Lösenorden matchar", + "passwords_do_not_match": "Lösenorden matchar inte", + "password_mismatch_alert": "Lösenorden matchar inte", + "creating_account_button": "Skapar konto...", + "create_account_button": "Skapa konto", + "or_continue_with": "Eller fortsätt med", + "continue_with_github": "Fortsätt med GitHub", + "already_have_account_question": "Har du redan ett konto?", + "sign_in_link": "Logga in", + "invite_code_label": "Inbjudningskod", + "invite_code_placeholder": "Ange din inbjudningskod", + "invite_code_required": "Inbjudningskod krävs", + "invalid_invite_code": "Ogiltig inbjudningskod", + "failed_to_validate_invite": "Misslyckades med att validera inbjudningskod", + "unexpected_error": "Ett oväntat fel inträffade. Försök igen.", + "email_domain_not_allowed": "E-postdomän ej tillåten", + "account_already_exists": "Ett konto med denna e-post finns redan. Vänligen logga in istället." + }, + "secret_form": { + "failed_to_create_secret": "Misslyckades med att skapa hemlighet: {{errorMessage}}", + "failed_to_upload_file": "Misslyckades med att ladda upp fil: {{fileName}}" + }, + "secret_page": { + "password_label": "Lösenord", + "password_placeholder": "Ange lösenord för att se hemlighet", + "decryption_key_label": "Dekrypteringsnyckel", + "decryption_key_placeholder": "Ange dekrypteringsnyckeln", + "view_secret_button": "Se hemlighet", + "views_remaining_tooltip": "Visningar kvar: {{count}}", + "loading_message": "Dekrypterar hemlighet...", + "files_title": "Bifogade filer", + "secret_waiting_title": "Någon delade en hemlighet med dig", + "secret_waiting_description": "Denna hemlighet är krypterad och kan endast visas när du klickar på knappen nedan.", + "one_view_remaining": "Denna hemlighet kan endast visas 1 gång till", + "views_remaining": "Denna hemlighet kan visas {{count}} gånger till", + "view_warning": "När den har visats kan denna åtgärd inte ångras", + "secret_revealed": "Hemlighet", + "copy_secret": "Kopiera till urklipp", + "download": "Ladda ner", + "create_your_own": "Skapa din egen hemlighet", + "encrypted_secret": "Krypterad hemlighet", + "unlock_secret": "Lås upp hemlighet", + "delete_secret": "Ta bort hemlighet", + "delete_modal_title": "Ta bort hemlighet", + "delete_modal_message": "Är du säker på att du vill ta bort denna hemlighet? Denna åtgärd kan inte ångras.", + "decryption_failed": "Misslyckades med att dekryptera hemligheten. Kontrollera ditt lösenord eller dekrypteringsnyckel.", + "fetch_error": "Ett fel inträffade vid hämtning av hemligheten. Försök igen." + }, + "expiration": { + "28_days": "28 dagar", + "14_days": "14 dagar", + "7_days": "7 dagar", + "3_days": "3 dagar", + "1_day": "1 dag", + "12_hours": "12 timmar", + "4_hours": "4 timmar", + "1_hour": "1 timme", + "30_minutes": "30 minuter", + "5_minutes": "5 minuter" + }, + "error_display": { + "clear_errors_button_title": "Rensa fel" + }, + "secret_not_found_page": { + "title": "Hemlighet hittades inte", + "message": "Hemligheten du letar efter finns inte, har gått ut, eller har bränts.", + "error_details": "Felinformation:", + "go_home_button": "Gå till hemsidan" + }, + "organization_page": { + "title": "Organisationsinställningar", + "description": "Konfigurera organisationsomfattande inställningar och åtkomstkontroller", + "registration_settings": { + "title": "Registreringsinställningar", + "description": "Kontrollera hur användare kan gå med i din organisation", + "invite_only_title": "Endast inbjudningsregistrering", + "invite_only_description": "Användare kan endast registrera sig med en giltig inbjudningskod", + "require_registered_user_title": "Endast registrerade användare", + "require_registered_user_description": "Endast registrerade användare kan skapa hemligheter", + "disable_email_password_signup_title": "Inaktivera e-post/lösenord-registrering", + "disable_email_password_signup_description": "Inaktivera registrering med e-post och lösenord (endast social inloggning)", + "allowed_domains_title": "Tillåtna e-postdomäner", + "allowed_domains_description": "Tillåt endast registrering från specifika e-postdomäner (kommaseparerad, t.ex., foretag.com, org.net)", + "allowed_domains_placeholder": "foretag.com, org.net", + "allowed_domains_hint": "Kommaseparerad lista över e-postdomäner. Lämna tomt för att tillåta alla domäner." + }, + "invite_codes": { + "title": "Inbjudningskoder", + "description": "Skapa och hantera inbjudningskoder för nya användare", + "create_invite_button": "Skapa inbjudningskod", + "code_header": "Kod", + "uses_header": "Användningar", + "expires_header": "Går ut", + "actions_header": "Åtgärder", + "unlimited": "Obegränsat", + "never": "Aldrig", + "expired": "Utgången", + "no_invites": "Inga inbjudningskoder än", + "no_invites_description": "Skapa en inbjudningskod för att tillåta nya användare att registrera sig", + "copy_tooltip": "Kopiera kod", + "delete_tooltip": "Ta bort kod" + }, + "create_invite_modal": { + "title": "Skapa inbjudningskod", + "max_uses_label": "Max användningar", + "max_uses_placeholder": "Lämna tomt för obegränsat", + "expiration_label": "Utgångsdatum", + "expiration_options": { + "never": "Aldrig", + "24_hours": "24 timmar", + "7_days": "7 dagar", + "30_days": "30 dagar" + }, + "cancel_button": "Avbryt", + "create_button": "Skapa" + }, + "saving_button": "Sparar...", + "save_settings_button": "Spara inställningar" + }, + "invites_page": { + "title": "Inbjudningskoder", + "description": "Hantera inbjudningskoder för nya användarregistreringar", + "create_invite_button": "Skapa inbjudan", + "loading": "Laddar inbjudningskoder...", + "table": { + "code_header": "Kod", + "uses_header": "Användningar", + "expires_header": "Går ut", + "status_header": "Status", + "never": "Aldrig" + }, + "status": { + "active": "Aktiv", + "expired": "Utgången", + "used": "Använd", + "inactive": "Inaktiv" + }, + "no_invites": "Inga inbjudningskoder än", + "create_modal": { + "title": "Skapa inbjudningskod", + "max_uses_label": "Maximal användning", + "expires_in_label": "Går ut om (dagar)" + }, + "delete_modal": { + "title": "Inaktivera inbjudningskod", + "confirm_text": "Inaktivera", + "cancel_text": "Avbryt", + "message": "Är du säker på att du vill inaktivera inbjudningskod {{code}}? Denna åtgärd kan inte ångras." + }, + "toast": { + "created": "Inbjudningskod skapad", + "deactivated": "Inbjudningskod inaktiverad", + "copied": "Inbjudningskod kopierad till urklipp", + "fetch_error": "Misslyckades med att hämta inbjudningskoder", + "create_error": "Misslyckades med att skapa inbjudningskod", + "delete_error": "Misslyckades med att inaktivera inbjudningskod" + } + }, + "social_login": { + "continue_with": "Fortsätt med {{provider}}", + "sign_up_with": "Registrera dig med {{provider}}" + }, + "setup_page": { + "title": "Välkommen till Hemmelig", + "description": "Skapa ditt administratörskonto för att komma igång", + "name_label": "Fullständigt namn", + "name_placeholder": "Ange ditt fullständiga namn", + "username_label": "Användarnamn", + "username_placeholder": "Välj ett användarnamn", + "email_label": "E-postadress", + "email_placeholder": "Ange din e-post", + "password_label": "Lösenord", + "password_placeholder": "Skapa ett lösenord (min 8 tecken)", + "confirm_password_label": "Bekräfta lösenord", + "confirm_password_placeholder": "Bekräfta ditt lösenord", + "create_admin": "Skapa administratörskonto", + "creating": "Skapar konto...", + "success": "Administratörskonto skapat framgångsrikt! Vänligen logga in.", + "error": "Misslyckades med att skapa administratörskonto", + "passwords_mismatch": "Lösenorden matchar inte", + "password_too_short": "Lösenordet måste vara minst 8 tecken", + "note": "Denna installation kan endast slutföras en gång. Administratörskontot kommer att ha full tillgång till att hantera denna instans." + }, + "theme_toggle": { + "switch_to_light": "Byt till ljust läge", + "switch_to_dark": "Byt till mörkt läge" + }, + "webhook_settings": { + "title": "Webhook-aviseringar", + "description": "Meddela externa tjänster när hemligheter visas eller bränns", + "enable_webhooks_title": "Aktivera webhooks", + "enable_webhooks_description": "Skicka HTTP POST-förfrågningar till din webhook-URL när händelser inträffar", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://exempel.se/webhook", + "webhook_url_hint": "URL:en där webhook-nyttolaster kommer att skickas", + "webhook_secret_label": "Webhook-hemlighet", + "webhook_secret_placeholder": "Ange en hemlighet för HMAC-signering", + "webhook_secret_hint": "Används för att signera webhook-nyttolaster med HMAC-SHA256. Signaturen skickas i X-Hemmelig-Signature-rubriken.", + "events_title": "Webhook-händelser", + "on_view_title": "Hemlighet visad", + "on_view_description": "Skicka en webhook när en hemlighet visas", + "on_burn_title": "Hemlighet bränd", + "on_burn_description": "Skicka en webhook när en hemlighet bränns eller raderas" + }, + "metrics_settings": { + "title": "Prometheus-mätvärden", + "description": "Exponera mätvärden för övervakning med Prometheus", + "enable_metrics_title": "Aktivera Prometheus-mätvärden", + "enable_metrics_description": "Exponera en /api/metrics slutpunkt för Prometheus-skrapning", + "metrics_secret_label": "Mätvärdeshemlighet", + "metrics_secret_placeholder": "Ange en hemlighet för autentisering", + "metrics_secret_hint": "Används som en Bearer-token för att autentisera förfrågningar till mätvärdesslutpunkten. Lämna tomt för ingen autentisering (rekommenderas inte).", + "endpoint_info_title": "Slutpunktsinformation", + "endpoint_info_description": "När aktiverat kommer mätvärden att vara tillgängliga på:", + "endpoint_auth_hint": "Inkludera hemligheten som en Bearer-token i Authorization-rubriken när du hämtar mätvärden." + }, + "verify_2fa_page": { + "back_to_login": "Tillbaka till inloggning", + "title": "Tvåfaktorsautentisering", + "description": "Ange den 6-siffriga koden från din autentiseringsapp", + "enter_code_hint": "Ange koden från din autentiseringsapp", + "verifying": "Verifierar...", + "verify_button": "Verifiera", + "invalid_code": "Ogiltig verifieringskod. Försök igen.", + "unexpected_error": "Ett oväntat fel inträffade. Försök igen." + }, + "common": { + "error": "Fel", + "cancel": "Avbryt", + "confirm": "Bekräfta", + "ok": "OK", + "delete": "Ta bort", + "deleting": "Tar bort...", + "loading": "Laddar..." + }, + "pagination": { + "showing": "Visar {{start}} till {{end}} av {{total}} resultat", + "previous_page": "Föregående sida", + "next_page": "Nästa sida" + }, + "not_found_page": { + "title": "Sidan hittades inte", + "message": "Denna sida har försvunnit i tomma intet, precis som våra hemligheter gör.", + "hint": "Sidan du letar efter finns inte eller har flyttats.", + "go_home_button": "Gå hem", + "create_secret_button": "Skapa hemlighet" + }, + "error_boundary": { + "title": "Något gick fel", + "message": "Ett oväntat fel inträffade vid behandling av din begäran.", + "hint": "Oroa dig inte, dina hemligheter är fortfarande säkra. Försök att uppdatera sidan.", + "error_details": "Felinformation:", + "unknown_error": "Ett okänt fel inträffade", + "try_again_button": "Försök igen", + "go_home_button": "Gå hem" + }, + "secret_requests_page": { + "title": "Hemlighetsförfrågningar", + "description": "Begär hemligheter från andra via säkra länkar", + "create_request_button": "Skapa förfrågan", + "no_requests": "Inga hemlighetsförfrågningar ännu. Skapa en för att komma igång.", + "table": { + "title_header": "Titel", + "status_header": "Status", + "secret_expiry_header": "Hemlighet upphör", + "link_expires_header": "Länk upphör", + "copy_link_tooltip": "Kopiera skaparlänk", + "view_secret_tooltip": "Visa hemlighet", + "cancel_tooltip": "Avbryt förfrågan" + }, + "status": { + "pending": "Väntande", + "fulfilled": "Uppfylld", + "expired": "Utgången", + "cancelled": "Avbruten" + }, + "time": { + "days": "{{count}} dag", + "days_plural": "{{count}} dagar", + "hours": "{{count}} timme", + "hours_plural": "{{count}} timmar", + "minutes": "{{count}} minut", + "minutes_plural": "{{count}} minuter" + }, + "link_modal": { + "title": "Skaparlänk", + "description": "Skicka denna länk till personen som ska tillhandahålla hemligheten. De kan ange och kryptera hemligheten via denna länk.", + "copy_button": "Kopiera länk", + "close_button": "Stäng", + "warning": "Denna länk kan endast användas en gång. När en hemlighet har skickats in kommer länken inte längre att fungera." + }, + "cancel_modal": { + "title": "Avbryt förfrågan", + "message": "Är du säker på att du vill avbryta förfrågan \"{{title}}\"? Denna åtgärd kan inte ångras.", + "confirm_text": "Avbryt förfrågan", + "cancel_text": "Behåll förfrågan" + }, + "toast": { + "copied": "Länk kopierad till urklipp", + "cancelled": "Förfrågan avbruten", + "fetch_error": "Kunde inte hämta förfrågningsdetaljer", + "cancel_error": "Kunde inte avbryta förfrågan" + } + }, + "create_request_page": { + "title": "Skapa hemlighetsförfrågan", + "description": "Begär en hemlighet från någon genom att generera en säker länk som de kan använda för att skicka in den", + "back_button": "Tillbaka till hemlighetsförfrågningar", + "form": { + "title_label": "Förfrågantitel", + "title_placeholder": "t.ex. AWS-uppgifter för Projekt X", + "description_label": "Beskrivning (valfritt)", + "description_placeholder": "Ge ytterligare sammanhang om vad du behöver...", + "link_validity_label": "Länkgiltighet", + "link_validity_hint": "Hur länge skaparlänken förblir aktiv", + "secret_settings_title": "Hemlighetsinställningar", + "secret_settings_description": "Dessa inställningar kommer att gälla för hemligheten när den har skapats", + "secret_expiration_label": "Hemlighet upphör", + "max_views_label": "Maximala visningar", + "password_label": "Lösenordsskydd (valfritt)", + "password_placeholder": "Ange ett lösenord (minst 5 tecken)", + "password_hint": "Mottagare behöver detta lösenord för att visa hemligheten", + "ip_restriction_label": "IP-begränsning (valfritt)", + "ip_restriction_placeholder": "192.168.1.0/24 eller 203.0.113.5", + "prevent_burn_label": "Förhindra auto-radering (behåll hemlighet efter max visningar)", + "webhook_title": "Webhook-notifikation (valfritt)", + "webhook_description": "Få en notifikation när hemligheten skickas in", + "webhook_url_label": "Webhook-URL", + "webhook_url_placeholder": "https://din-server.com/webhook", + "webhook_url_hint": "HTTPS rekommenderas. En notifikation skickas när hemligheten skapas.", + "creating_button": "Skapar...", + "create_button": "Skapa förfrågan" + }, + "validity": { + "30_days": "30 dagar", + "14_days": "14 dagar", + "7_days": "7 dagar", + "3_days": "3 dagar", + "1_day": "1 dag", + "12_hours": "12 timmar", + "1_hour": "1 timme" + }, + "success": { + "title": "Förfrågan skapad!", + "description": "Dela skaparlänken med personen som ska tillhandahålla hemligheten", + "creator_link_label": "Skaparlänk", + "webhook_secret_label": "Webhook-hemlighet", + "webhook_secret_warning": "Spara denna hemlighet nu! Den visas inte igen. Använd den för att verifiera webhook-signaturer.", + "expires_at": "Länk upphör: {{date}}", + "create_another_button": "Skapa en ny förfrågan", + "view_all_button": "Visa alla förfrågningar" + }, + "toast": { + "created": "Hemlighetsförfrågan skapad", + "create_error": "Kunde inte skapa hemlighetsförfrågan", + "copied": "Kopierat till urklipp" + } + }, + "request_secret_page": { + "loading": "Laddar förfrågan...", + "error": { + "title": "Förfrågan inte tillgänglig", + "invalid_link": "Denna länk är ogiltig eller har manipulerats.", + "not_found": "Denna förfrågan hittades inte eller så är länken ogiltig.", + "already_fulfilled": "Denna förfrågan har redan uppfyllts eller har gått ut.", + "generic": "Ett fel inträffade vid inläsning av förfrågan.", + "go_home_button": "Gå till startsidan" + }, + "form": { + "title": "Skicka in en hemlighet", + "description": "Någon har bett dig att dela en hemlighet med dem på ett säkert sätt", + "password_protected_note": "Denna hemlighet kommer att vara lösenordsskyddad", + "encryption_note": "Din hemlighet krypteras i din webbläsare innan den skickas. Dekrypteringsnyckeln inkluderas endast i den slutliga URL:en du delar.", + "submitting_button": "Krypterar och skickar in...", + "submit_button": "Skicka in hemlighet" + }, + "success": { + "title": "Hemlighet skapad!", + "description": "Din hemlighet har krypterats och lagrats säkert", + "decryption_key_label": "Dekrypteringsnyckel", + "warning": "Viktigt: Kopiera denna dekrypteringsnyckel nu och skicka den till den som begärde den. Detta är den enda gången du kommer att se den!", + "manual_send_note": "Du måste manuellt skicka denna dekrypteringsnyckel till personen som begärde hemligheten. De har redan hemlighetens URL i sin instrumentpanel.", + "create_own_button": "Skapa din egen hemlighet" + }, + "toast": { + "created": "Hemlighet skickad", + "create_error": "Kunde inte skicka in hemlighet", + "copied": "Kopierad till urklipp" + } + } +} \ No newline at end of file diff --git a/src/i18n/locales/zh/zh.json b/src/i18n/locales/zh/zh.json new file mode 100644 index 0000000..4116e77 --- /dev/null +++ b/src/i18n/locales/zh/zh.json @@ -0,0 +1,966 @@ +{ + "template_selector": { + "button": "模板", + "description": "使用模板快速开始", + "templates": { + "credentials": "登录凭据", + "api_key": "API 密钥", + "database": "数据库", + "server": "服务器访问", + "credit_card": "支付卡", + "email": "电子邮件账户" + } + }, + "editor": { + "tooltips": { + "copy_text": "复制为纯文本", + "copy_html": "复制为 HTML", + "copy_base64": "复制为 Base64", + "bold": "粗体", + "italic": "斜体", + "strikethrough": "删除线", + "inline_code": "行内代码", + "link": "链接", + "remove_link": "移除链接", + "insert_password": "插入密码", + "paragraph": "段落", + "heading1": "标题 1", + "heading2": "标题 2", + "heading3": "标题 3", + "bullet_list": "无序列表", + "numbered_list": "有序列表", + "blockquote": "引用", + "code_block": "代码块", + "undo": "撤销", + "redo": "重做" + }, + "copy_success": { + "html": "HTML 已复制!", + "text": "文本已复制!", + "base64": "Base64 已复制!" + }, + "link_modal": { + "title": "添加链接", + "url_label": "URL", + "url_placeholder": "输入 URL", + "cancel": "取消", + "update": "更新", + "insert": "插入" + }, + "password_modal": { + "title": "生成密码", + "length_label": "密码长度", + "options_label": "选项", + "include_numbers": "数字", + "include_symbols": "符号", + "include_uppercase": "大写字母", + "include_lowercase": "小写字母", + "generated_password": "生成的密码", + "refresh": "刷新", + "cancel": "取消", + "insert": "插入", + "copied_and_added": "密码已添加并复制到剪贴板", + "added": "密码已添加" + }, + "formatting_tools": "格式化工具", + "character_count": "字符" + }, + "create_button": { + "creating_secret": "正在创建秘密...", + "create": "创建" + }, + "file_upload": { + "sign_in_to_upload": "登录以上传文件", + "sign_in": "登录", + "drop_files_here": "将文件拖放到此处", + "drag_and_drop": "拖放文件,或点击选择文件", + "uploading": "正在上传...", + "upload_file": "上传文件", + "file_too_large": "文件 \"{{fileName}}\"({{fileSize}} MB)超过最大大小 {{maxSize}} MB", + "max_size_exceeded": "文件总大小超过最大限制 {{maxSize}} MB" + }, + "footer": { + "tagline": "Hemmelig,[heˈm(ɛ)li],在挪威语中意为「秘密」", + "privacy": "隐私", + "terms": "条款", + "api": "API", + "managed_hosting": "托管服务", + "sponsored_by": "Hosted by cloudhost.es" + }, + "header": { + "home": "首页", + "sign_in": "登录", + "sign_up": "注册", + "dashboard": "仪表板", + "hero_text_part1": "安全分享加密消息,阅读后", + "hero_text_part2": "自动销毁", + "hero_text_part3": "。" + }, + "dashboard_layout": { + "secrets": "秘密", + "secret_requests": "秘密请求", + "account": "账户", + "analytics": "分析", + "users": "用户", + "invites": "邀请", + "instance": "实例", + "sign_out": "退出登录", + "hemmelig": "paste.es" + }, + "secret_settings": { + "secret_created_title": "秘密已创建!", + "secret_created_description": "您的秘密现在可以通过以下 URL 访问。请妥善保管您的解密密钥,因为它无法恢复。", + "secret_url_label": "秘密 URL", + "decryption_key_label": "解密密钥", + "password_label": "密码", + "create_new_secret_button": "创建新秘密", + "copy_url_button": "复制 URL", + "burn_secret_button": "销毁秘密", + "max_secrets_per_user_info": "您最多可以创建 {{count}} 个秘密。", + "failed_to_burn": "销毁秘密失败。请重试。" + }, + "security_settings": { + "security_title": "安全", + "security_description": "配置秘密的安全设置", + "remember_settings": "记住", + "private_title": "私密", + "private_description": "私密秘密已加密,只能使用解密密钥和/或密码查看。", + "expiration_title": "过期时间", + "expiration_burn_after_time_description": "设置秘密应该被销毁的时间", + "expiration_default_description": "设置秘密的可用时长", + "max_views_title": "最大查看次数", + "burn_after_time_mode_title": "定时销毁模式", + "burn_after_time_mode_description": "秘密将在时间到期后销毁,无论被查看了多少次。", + "password_protection_title": "密码保护", + "password_protection_description": "使用密码添加额外的安全层", + "enter_password_label": "输入密码", + "password_placeholder": "输入安全密码...", + "password_hint": "最少5个字符。接收者需要此密码才能查看秘密", + "password_error": "密码必须至少包含5个字符", + "ip_restriction_title": "IP 或 CIDR 限制", + "ip_restriction_description": "CIDR 输入将允许用户指定可以访问秘密的 IP 地址范围。", + "ip_address_cidr_label": "IP 地址或 CIDR 范围", + "ip_address_cidr_placeholder": "192.168.1.0/24 或 203.0.113.5", + "ip_address_cidr_hint": "只有来自这些 IP 地址的请求才能访问秘密", + "burn_after_time_title": "过期后销毁", + "burn_after_time_description": "仅在时间到期后销毁秘密" + }, + "title_field": { + "placeholder": "标题", + "hint": "为您的秘密起一个易记的标题(可选)" + }, + "views_slider": { + "min_views": "1", + "max_views": "999", + "views_label": "次查看" + }, + "account_page": { + "title": "账户设置", + "description": "管理您的账户偏好和安全设置", + "tabs": { + "profile": "个人资料", + "security": "安全", + "developer": "开发者", + "danger_zone": "危险区域" + }, + "profile_info": { + "title": "个人资料信息", + "description": "更新您的个人信息", + "first_name_label": "名", + "last_name_label": "姓", + "username_label": "用户名", + "email_label": "电子邮件地址", + "saving_button": "正在保存...", + "save_changes_button": "保存更改" + }, + "profile_settings": { + "username_taken": "用户名已被使用" + }, + "security_settings": { + "title": "安全设置", + "description": "管理您的密码和安全偏好", + "change_password_title": "更改密码", + "current_password_label": "当前密码", + "current_password_placeholder": "输入当前密码", + "new_password_label": "新密码", + "new_password_placeholder": "输入新密码", + "confirm_new_password_label": "确认新密码", + "confirm_new_password_placeholder": "确认新密码", + "password_mismatch_alert": "新密码不匹配", + "changing_password_button": "正在更改...", + "change_password_button": "更改密码", + "password_change_success": "密码更改成功!", + "password_change_error": "密码更改失败。请重试。" + }, + "two_factor": { + "title": "双因素身份验证", + "description": "为您的账户添加额外的安全层", + "enabled": "已启用", + "disabled": "未启用", + "setup_button": "设置 2FA", + "disable_button": "禁用 2FA", + "enter_password_to_enable": "输入您的密码以启用双因素身份验证。", + "continue": "继续", + "scan_qr_code": "使用您的身份验证应用扫描此二维码(Google Authenticator、Authy 等)。", + "manual_entry_hint": "或在您的身份验证应用中手动输入此代码:", + "enter_verification_code": "输入身份验证应用中的 6 位数代码以验证设置。", + "verification_code": "验证码", + "verify_and_enable": "验证并启用", + "back": "返回", + "disable_title": "禁用双因素身份验证", + "disable_warning": "禁用 2FA 将使您的账户安全性降低。您需要输入密码进行确认。", + "invalid_password": "密码无效。请重试。", + "invalid_code": "验证码无效。请重试。", + "enable_error": "启用 2FA 失败。请重试。", + "verify_error": "验证 2FA 代码失败。请重试。", + "disable_error": "禁用 2FA 失败。请重试。", + "backup_codes_title": "备用码", + "backup_codes_description": "将这些备用码保存在安全的地方。如果您无法访问身份验证应用,可以使用它们访问您的账户。", + "backup_codes_warning": "每个代码只能使用一次。请妥善保管!", + "backup_codes_saved": "我已保存我的备用码" + }, + "danger_zone": { + "title": "危险区域", + "description": "不可逆的破坏性操作", + "delete_account_title": "删除账户", + "delete_account_description": "一旦删除您的账户,将无法恢复。这将永久删除您的账户、所有秘密和所有相关数据。此操作无法撤销。", + "delete_account_bullet1": "您的所有秘密将被永久删除", + "delete_account_bullet2": "您的账户数据将从我们的服务器中移除", + "delete_account_bullet3": "所有共享的秘密链接将失效", + "delete_account_bullet4": "此操作无法撤销", + "delete_account_confirm": "您确定要删除您的账户吗?此操作无法撤销。", + "delete_account_button": "删除账户", + "deleting_account_button": "正在删除账户..." + }, + "developer": { + "title": "API 密钥", + "description": "管理用于程序化访问的 API 密钥", + "create_key": "创建密钥", + "create_key_title": "创建 API 密钥", + "key_name": "密钥名称", + "key_name_placeholder": "例如:我的集成", + "expiration": "过期时间", + "never_expires": "永不过期", + "expires_30_days": "30 天", + "expires_90_days": "90 天", + "expires_1_year": "1 年", + "create_button": "创建", + "name_required": "密钥名称为必填项", + "create_error": "创建 API 密钥失败", + "key_created": "API 密钥已创建!", + "key_warning": "请立即复制此密钥。您将无法再次查看它。", + "dismiss": "我已复制密钥", + "no_keys": "还没有 API 密钥。创建一个开始使用。", + "created": "创建时间", + "last_used": "最后使用", + "expires": "过期时间", + "docs_hint": "了解如何使用 API 密钥,请查看", + "api_docs": "API 文档" + } + }, + "analytics_page": { + "title": "分析", + "description": "跟踪您的秘密分享活动和洞察", + "time_range": { + "last_7_days": "最近 7 天", + "last_14_days": "最近 14 天", + "last_30_days": "最近 30 天" + }, + "total_secrets": "秘密总数", + "from_last_period": "较上期 +{{percentage}}%", + "total_views": "总查看次数", + "avg_views_per_secret": "平均查看/秘密", + "active_secrets": "活跃秘密", + "daily_activity": { + "title": "每日活动", + "description": "随时间创建的秘密和查看次数", + "secrets": "秘密", + "views": "查看", + "secrets_created": "创建的秘密", + "secret_views": "秘密浏览量", + "date": "日期", + "trend": "变化", + "vs_previous": "对比前一天", + "no_data": "暂无活动数据。" + }, + "locale": "zh-CN", + "top_countries": { + "title": "热门国家/地区", + "description": "您的秘密被查看的地区", + "views": "查看" + }, + "secret_types": { + "title": "秘密类型", + "description": "按保护级别分布", + "password_protected": "密码保护", + "ip_restricted": "IP 限制", + "burn_after_time": "定时销毁" + }, + "expiration_stats": { + "title": "过期统计", + "description": "秘密通常持续多长时间", + "one_hour": "1 小时", + "one_day": "1 天", + "one_week_plus": "1 周以上" + }, + "visitor_analytics": { + "title": "访客分析", + "description": "页面浏览量和独立访客", + "unique": "独立访客", + "views": "浏览量", + "date": "日期", + "trend": "变化", + "vs_previous": "对比前一天", + "no_data": "暂无访客数据。" + }, + "secret_requests": { + "total": "密钥请求", + "fulfilled": "已完成请求" + }, + "loading": "正在加载分析...", + "no_permission": "您没有权限查看分析。", + "failed_to_fetch": "获取分析数据失败。" + }, + "instance_page": { + "title": "实例设置", + "description": "配置您的 Hemmelig 实例", + "managed_mode": { + "title": "托管模式", + "description": "此实例通过环境变量进行管理。设置为只读。" + }, + "tabs": { + "general": "常规", + "security": "安全", + "organization": "组织", + "webhook": "Webhook", + "metrics": "指标" + }, + "system_status": { + "title": "系统状态", + "description": "实例健康状况和性能指标", + "version": "版本", + "uptime": "运行时间", + "memory": "内存", + "cpu_usage": "CPU 使用率" + }, + "general_settings": { + "title": "常规设置", + "description": "基本实例配置", + "instance_name_label": "实例名称", + "logo_label": "实例徽标", + "logo_upload": "上传徽标", + "logo_remove": "移除徽标", + "logo_hint": "PNG、JPEG、GIF、SVG 或 WebP。最大 512KB。", + "logo_alt": "实例徽标", + "logo_invalid_type": "文件类型无效。请上传 PNG、JPEG、GIF、SVG 或 WebP 图片。", + "logo_too_large": "文件太大。最大大小为 512KB。", + "default_expiration_label": "默认过期时间", + "max_secrets_per_user_label": "每用户最大秘密数", + "max_secret_size_label": "最大秘密大小 (MB)", + "instance_description_label": "实例描述", + "important_message_label": "重要消息", + "important_message_placeholder": "输入要向所有用户显示的重要消息...", + "important_message_hint": "此消息将在首页显示为警告横幅。留空以隐藏。", + "allow_registration_title": "允许注册", + "allow_registration_description": "允许新用户注册", + "email_verification_title": "邮箱验证", + "email_verification_description": "需要邮箱验证" + }, + "saving_button": "正在保存...", + "save_settings_button": "保存设置", + "security_settings": { + "title": "安全设置", + "description": "配置安全和访问控制", + "rate_limiting_title": "速率限制", + "rate_limiting_description": "启用请求速率限制", + "max_password_attempts_label": "最大密码尝试次数", + "session_timeout_label": "会话超时(小时)", + "allow_file_uploads_title": "允许文件上传", + "allow_file_uploads_description": "允许用户将文件附加到密钥" + }, + "email_settings": { + "title": "邮件设置", + "description": "配置 SMTP 和邮件通知", + "smtp_host_label": "SMTP 主机", + "smtp_port_label": "SMTP 端口", + "username_label": "用户名", + "password_label": "密码" + }, + "database_info": { + "title": "数据库信息", + "description": "数据库状态和统计", + "stats_title": "数据库统计", + "total_secrets": "秘密总数:", + "total_users": "用户总数:", + "disk_usage": "磁盘使用:", + "connection_status_title": "连接状态", + "connected": "已连接", + "connected_description": "数据库健康且响应正常" + }, + "system_info": { + "title": "系统信息", + "description": "服务器详情和维护", + "system_info_title": "系统信息", + "version": "版本:", + "uptime": "运行时间:", + "status": "状态:", + "resource_usage_title": "资源使用", + "memory": "内存:", + "cpu": "CPU:", + "disk": "磁盘:" + }, + "maintenance_actions": { + "title": "维护操作", + "description": "这些操作可能影响系统可用性。请谨慎使用。", + "restart_service_button": "重启服务", + "clear_cache_button": "清除缓存", + "export_logs_button": "导出日志" + } + }, + "secrets_page": { + "title": "您的秘密", + "description": "管理和监控您共享的秘密", + "create_secret_button": "创建秘密", + "search_placeholder": "搜索秘密...", + "filter": { + "all_secrets": "所有秘密", + "active": "活跃", + "expired": "已过期" + }, + "total_secrets": "秘密总数", + "active_secrets": "活跃", + "expired_secrets": "已过期", + "no_secrets_found_title": "未找到秘密", + "no_secrets_found_description_filter": "尝试调整您的搜索或筛选条件。", + "no_secrets_found_description_empty": "创建您的第一个秘密以开始。", + "password_protected": "密码", + "files": "文件", + "table": { + "secret_header": "秘密", + "created_header": "创建时间", + "status_header": "状态", + "views_header": "查看次数", + "actions_header": "操作", + "untitled_secret": "无标题秘密", + "expired_status": "已过期", + "active_status": "活跃", + "never_expires": "永不过期", + "expired_time": "已过期", + "views_left": "剩余查看次数", + "copy_url_tooltip": "复制 URL", + "open_secret_tooltip": "打开秘密", + "delete_secret_tooltip": "删除秘密", + "delete_confirmation_title": "确定吗?", + "delete_confirmation_text": "此操作无法撤销。秘密将被永久删除。", + "delete_confirm_button": "是的,删除", + "delete_cancel_button": "取消" + } + }, + "users_page": { + "title": "用户管理", + "description": "管理用户及其权限", + "add_user_button": "添加用户", + "search_placeholder": "搜索用户...", + "filter": { + "all_roles": "所有角色", + "admin": "管理员", + "user": "用户", + "all_status": "所有状态", + "active": "活跃", + "suspended": "已暂停", + "pending": "待处理" + }, + "total_users": "用户总数", + "active_users": "活跃", + "admins": "管理员", + "pending_users": "待处理", + "no_users_found_title": "未找到用户", + "no_users_found_description_filter": "尝试调整您的搜索或筛选条件。", + "no_users_found_description_empty": "尚未添加任何用户。", + "table": { + "user_header": "用户", + "role_header": "角色", + "status_header": "状态", + "activity_header": "活动", + "last_login_header": "最后登录", + "actions_header": "操作", + "created_at": "创建时间" + }, + "status": { + "active": "活跃", + "banned": "已封禁" + }, + "delete_user_modal": { + "title": "删除用户", + "confirmation_message": "您确定要删除用户 {{username}} 吗?此操作无法撤销。", + "confirm_button": "删除", + "cancel_button": "取消" + }, + "edit_user_modal": { + "title": "编辑用户:{{username}}", + "username_label": "用户名", + "email_label": "电子邮件", + "role_label": "角色", + "banned_label": "已封禁", + "save_button": "保存", + "cancel_button": "取消" + }, + "add_user_modal": { + "title": "添加新用户", + "name_label": "姓名", + "username_label": "用户名", + "email_label": "电子邮件", + "password_label": "密码", + "role_label": "角色", + "save_button": "添加用户", + "cancel_button": "取消" + } + }, + "forgot_password_page": { + "back_to_sign_in": "返回登录", + "check_email_title": "检查您的邮箱", + "check_email_description": "我们已向 {{email}} 发送了密码重置链接", + "did_not_receive_email": "没有收到邮件?请检查垃圾邮件文件夹或重试。", + "try_again_button": "重试", + "forgot_password_title": "忘记密码?", + "forgot_password_description": "别担心,我们会向您发送重置说明", + "email_label": "电子邮件", + "email_placeholder": "输入您的电子邮件", + "email_hint": "输入与您账户关联的电子邮件", + "sending_button": "正在发送...", + "reset_password_button": "重置密码", + "remember_password": "记得您的密码?", + "sign_in_link": "登录", + "unexpected_error": "发生意外错误。请重试。" + }, + "login_page": { + "back_to_hemmelig": "返回 Hemmelig", + "welcome_back": "欢迎回到 Hemmelig", + "welcome_back_title": "欢迎回来", + "welcome_back_description": "登录您的 Hemmelig 账户", + "username_label": "用户名", + "username_placeholder": "输入您的用户名", + "password_label": "密码", + "password_placeholder": "输入您的密码", + "forgot_password_link": "忘记密码?", + "signing_in_button": "正在登录...", + "sign_in_button": "登录", + "or_continue_with": "或者继续使用", + "continue_with_github": "使用 GitHub 继续", + "no_account_question": "没有账户?", + "sign_up_link": "注册", + "unexpected_error": "发生意外错误。请重试。" + }, + "register_page": { + "back_to_hemmelig": "返回 Hemmelig", + "join_hemmelig": "加入 Hemmelig 安全分享秘密", + "email_password_disabled_message": "邮箱和密码注册已禁用。请使用下方的社交登录选项。", + "create_account_title": "创建账户", + "create_account_description": "加入 Hemmelig 安全分享秘密", + "username_label": "用户名", + "username_placeholder": "选择用户名", + "email_label": "电子邮件", + "email_placeholder": "输入您的电子邮件", + "password_label": "密码", + "password_placeholder": "创建密码", + "password_strength_label": "密码强度", + "password_strength_levels": { + "very_weak": "非常弱", + "weak": "弱", + "fair": "一般", + "good": "良好", + "strong": "强" + }, + "confirm_password_label": "确认密码", + "confirm_password_placeholder": "确认您的密码", + "passwords_match": "密码匹配", + "passwords_do_not_match": "密码不匹配", + "password_mismatch_alert": "密码不匹配", + "creating_account_button": "正在创建账户...", + "create_account_button": "创建账户", + "or_continue_with": "或者继续使用", + "continue_with_github": "使用 GitHub 继续", + "already_have_account_question": "已有账户?", + "sign_in_link": "登录", + "invite_code_label": "邀请码", + "invite_code_placeholder": "输入您的邀请码", + "invite_code_required": "需要邀请码", + "invalid_invite_code": "邀请码无效", + "failed_to_validate_invite": "验证邀请码失败", + "unexpected_error": "发生意外错误。请重试。", + "email_domain_not_allowed": "不允许的邮箱域名", + "account_already_exists": "此邮箱已存在账户。请登录。" + }, + "secret_form": { + "failed_to_create_secret": "创建秘密失败:{{errorMessage}}", + "failed_to_upload_file": "上传文件失败:{{fileName}}" + }, + "secret_page": { + "password_label": "密码", + "password_placeholder": "输入密码以查看秘密", + "decryption_key_label": "解密密钥", + "decryption_key_placeholder": "输入解密密钥", + "view_secret_button": "查看秘密", + "views_remaining_tooltip": "剩余查看次数:{{count}}", + "loading_message": "正在解密秘密...", + "files_title": "附件", + "secret_waiting_title": "有人与您分享了一个秘密", + "secret_waiting_description": "此秘密已加密,只有点击下方按钮才能查看。", + "one_view_remaining": "此秘密只能再查看 1 次", + "views_remaining": "此秘密还可以查看 {{count}} 次", + "view_warning": "一旦查看,此操作无法撤销", + "secret_revealed": "秘密", + "copy_secret": "复制到剪贴板", + "download": "下载", + "create_your_own": "创建您自己的秘密", + "encrypted_secret": "加密秘密", + "unlock_secret": "解锁秘密", + "delete_secret": "删除秘密", + "delete_modal_title": "删除秘密", + "delete_modal_message": "您确定要删除此秘密吗?此操作无法撤消。", + "decryption_failed": "解密秘密失败。请检查您的密码或解密密钥。", + "fetch_error": "获取秘密时发生错误。请重试。" + }, + "expiration": { + "28_days": "28 天", + "14_days": "14 天", + "7_days": "7 天", + "3_days": "3 天", + "1_day": "1 天", + "12_hours": "12 小时", + "4_hours": "4 小时", + "1_hour": "1 小时", + "30_minutes": "30 分钟", + "5_minutes": "5 分钟" + }, + "error_display": { + "clear_errors_button_title": "清除错误" + }, + "secret_not_found_page": { + "title": "秘密未找到", + "message": "您查找的秘密不存在、已过期或已被销毁。", + "error_details": "错误详情:", + "go_home_button": "返回首页" + }, + "organization_page": { + "title": "组织设置", + "description": "配置组织范围的设置和访问控制", + "registration_settings": { + "title": "注册设置", + "description": "控制用户如何加入您的组织", + "invite_only_title": "仅限邀请注册", + "invite_only_description": "用户只能使用有效的邀请码注册", + "require_registered_user_title": "仅限注册用户", + "require_registered_user_description": "只有注册用户才能创建秘密", + "disable_email_password_signup_title": "禁用邮箱/密码注册", + "disable_email_password_signup_description": "禁用邮箱和密码注册(仅允许社交登录)", + "allowed_domains_title": "允许的邮箱域名", + "allowed_domains_description": "仅允许特定邮箱域名注册(逗号分隔,如 company.com, org.net)", + "allowed_domains_placeholder": "company.com, org.net", + "allowed_domains_hint": "逗号分隔的邮箱域名列表。留空则允许所有域名。" + }, + "invite_codes": { + "title": "邀请码", + "description": "创建和管理新用户的邀请码", + "create_invite_button": "创建邀请码", + "code_header": "代码", + "uses_header": "使用次数", + "expires_header": "过期时间", + "actions_header": "操作", + "unlimited": "无限制", + "never": "永不", + "expired": "已过期", + "no_invites": "暂无邀请码", + "no_invites_description": "创建邀请码以允许新用户注册", + "copy_tooltip": "复制代码", + "delete_tooltip": "删除代码" + }, + "create_invite_modal": { + "title": "创建邀请码", + "max_uses_label": "最大使用次数", + "max_uses_placeholder": "留空表示无限制", + "expiration_label": "过期时间", + "expiration_options": { + "never": "永不", + "24_hours": "24 小时", + "7_days": "7 天", + "30_days": "30 天" + }, + "cancel_button": "取消", + "create_button": "创建" + }, + "saving_button": "正在保存...", + "save_settings_button": "保存设置" + }, + "invites_page": { + "title": "邀请码", + "description": "管理新用户注册的邀请码", + "create_invite_button": "创建邀请", + "loading": "正在加载邀请码...", + "table": { + "code_header": "代码", + "uses_header": "使用次数", + "expires_header": "过期时间", + "status_header": "状态", + "never": "永不" + }, + "status": { + "active": "活跃", + "expired": "已过期", + "used": "已使用", + "inactive": "未激活" + }, + "no_invites": "暂无邀请码", + "create_modal": { + "title": "创建邀请码", + "max_uses_label": "最大使用次数", + "expires_in_label": "过期时间(天)" + }, + "delete_modal": { + "title": "停用邀请码", + "confirm_text": "停用", + "cancel_text": "取消", + "message": "您确定要停用邀请码 {{code}} 吗?此操作无法撤销。" + }, + "toast": { + "created": "邀请码已创建", + "deactivated": "邀请码已停用", + "copied": "邀请码已复制到剪贴板", + "fetch_error": "获取邀请码失败", + "create_error": "创建邀请码失败", + "delete_error": "停用邀请码失败" + } + }, + "social_login": { + "continue_with": "使用 {{provider}} 继续", + "sign_up_with": "使用 {{provider}} 注册" + }, + "setup_page": { + "title": "欢迎使用 Hemmelig", + "description": "创建您的管理员账户以开始使用", + "name_label": "全名", + "name_placeholder": "输入您的全名", + "username_label": "用户名", + "username_placeholder": "选择用户名", + "email_label": "电子邮件地址", + "email_placeholder": "输入您的电子邮件", + "password_label": "密码", + "password_placeholder": "创建密码(至少 8 个字符)", + "confirm_password_label": "确认密码", + "confirm_password_placeholder": "确认您的密码", + "create_admin": "创建管理员账户", + "creating": "正在创建账户...", + "success": "管理员账户创建成功!请登录。", + "error": "创建管理员账户失败", + "passwords_mismatch": "密码不匹配", + "password_too_short": "密码必须至少 8 个字符", + "note": "此设置只能完成一次。管理员账户将拥有完全访问权限来管理此实例。" + }, + "theme_toggle": { + "switch_to_light": "切换到亮色模式", + "switch_to_dark": "切换到暗色模式" + }, + "webhook_settings": { + "title": "Webhook 通知", + "description": "当秘密被查看或销毁时通知外部服务", + "enable_webhooks_title": "启用 Webhook", + "enable_webhooks_description": "当事件发生时向您的 Webhook URL 发送 HTTP POST 请求", + "webhook_url_label": "Webhook URL", + "webhook_url_placeholder": "https://example.com/webhook", + "webhook_url_hint": "Webhook 负载将发送到的 URL", + "webhook_secret_label": "Webhook 密钥", + "webhook_secret_placeholder": "输入用于 HMAC 签名的密钥", + "webhook_secret_hint": "用于使用 HMAC-SHA256 签名 Webhook 负载。签名在 X-Hemmelig-Signature 头中发送。", + "events_title": "Webhook 事件", + "on_view_title": "秘密已查看", + "on_view_description": "当秘密被查看时发送 Webhook", + "on_burn_title": "秘密已销毁", + "on_burn_description": "当秘密被销毁或删除时发送 Webhook" + }, + "metrics_settings": { + "title": "Prometheus 指标", + "description": "暴露用于 Prometheus 监控的指标", + "enable_metrics_title": "启用 Prometheus 指标", + "enable_metrics_description": "暴露一个 /api/metrics 端点供 Prometheus 抓取", + "metrics_secret_label": "指标密钥", + "metrics_secret_placeholder": "输入用于身份验证的密钥", + "metrics_secret_hint": "用作 Bearer 令牌来验证对指标端点的请求。留空表示不进行身份验证(不推荐)。", + "endpoint_info_title": "端点信息", + "endpoint_info_description": "启用后,指标将在以下位置可用:", + "endpoint_auth_hint": "获取指标时,请在 Authorization 头中包含密钥作为 Bearer 令牌。" + }, + "verify_2fa_page": { + "back_to_login": "返回登录", + "title": "双因素身份验证", + "description": "输入身份验证应用中的 6 位数代码", + "enter_code_hint": "输入身份验证应用中的代码", + "verifying": "正在验证...", + "verify_button": "验证", + "invalid_code": "验证码无效。请重试。", + "unexpected_error": "发生意外错误。请重试。" + }, + "common": { + "error": "错误", + "cancel": "取消", + "confirm": "确认", + "ok": "确定", + "delete": "删除", + "deleting": "删除中...", + "loading": "加载中..." + }, + "pagination": { + "showing": "显示第 {{start}} 至 {{end}} 条,共 {{total}} 条结果", + "previous_page": "上一页", + "next_page": "下一页" + }, + "not_found_page": { + "title": "页面未找到", + "message": "这个页面已经消失了,就像我们的秘密一样。", + "hint": "您要查找的页面不存在或已被移动。", + "go_home_button": "返回首页", + "create_secret_button": "创建秘密" + }, + "error_boundary": { + "title": "出错了", + "message": "处理您的请求时发生了意外错误。", + "hint": "别担心,您的秘密仍然安全。请尝试刷新页面。", + "error_details": "错误详情:", + "unknown_error": "发生了未知错误", + "try_again_button": "重试", + "go_home_button": "返回首页" + }, + "secret_requests_page": { + "title": "秘密请求", + "description": "通过安全链接向他人请求秘密", + "create_request_button": "创建请求", + "no_requests": "暂无秘密请求。创建一个开始吧。", + "table": { + "title_header": "标题", + "status_header": "状态", + "secret_expiry_header": "秘密过期", + "link_expires_header": "链接过期", + "copy_link_tooltip": "复制创建者链接", + "view_secret_tooltip": "查看秘密", + "cancel_tooltip": "取消请求" + }, + "status": { + "pending": "待处理", + "fulfilled": "已完成", + "expired": "已过期", + "cancelled": "已取消" + }, + "time": { + "days": "{{count}} 天", + "days_plural": "{{count}} 天", + "hours": "{{count}} 小时", + "hours_plural": "{{count}} 小时", + "minutes": "{{count}} 分钟", + "minutes_plural": "{{count}} 分钟" + }, + "link_modal": { + "title": "创建者链接", + "description": "将此链接发送给需要提供秘密的人。他们可以使用此链接输入并加密秘密。", + "copy_button": "复制链接", + "close_button": "关闭", + "warning": "此链接只能使用一次。一旦提交了秘密,链接将不再有效。" + }, + "cancel_modal": { + "title": "取消请求", + "message": "您确定要取消请求 \"{{title}}\" 吗?此操作无法撤销。", + "confirm_text": "取消请求", + "cancel_text": "保留请求" + }, + "toast": { + "copied": "链接已复制到剪贴板", + "cancelled": "请求已取消", + "fetch_error": "获取请求详情失败", + "cancel_error": "取消请求失败" + } + }, + "create_request_page": { + "title": "创建秘密请求", + "description": "通过生成安全链接向他人请求秘密,他们可以使用该链接提交秘密", + "back_button": "返回秘密请求", + "form": { + "title_label": "请求标题", + "title_placeholder": "例如:项目 X 的 AWS 凭证", + "description_label": "描述(可选)", + "description_placeholder": "提供关于您需要什么的额外背景...", + "link_validity_label": "链接有效期", + "link_validity_hint": "创建者链接保持活动的时间", + "secret_settings_title": "秘密设置", + "secret_settings_description": "这些设置将在秘密创建后应用", + "secret_expiration_label": "秘密过期时间", + "max_views_label": "最大查看次数", + "password_label": "密码保护(可选)", + "password_placeholder": "输入密码(至少5个字符)", + "password_hint": "接收者需要此密码才能查看秘密", + "ip_restriction_label": "IP 限制(可选)", + "ip_restriction_placeholder": "192.168.1.0/24 或 203.0.113.5", + "prevent_burn_label": "防止自动销毁(达到最大查看次数后保留秘密)", + "webhook_title": "Webhook 通知(可选)", + "webhook_description": "在秘密提交时收到通知", + "webhook_url_label": "Webhook URL", + "webhook_url_placeholder": "https://您的服务器.com/webhook", + "webhook_url_hint": "建议使用 HTTPS。秘密创建后将发送通知。", + "creating_button": "创建中...", + "create_button": "创建请求" + }, + "validity": { + "30_days": "30 天", + "14_days": "14 天", + "7_days": "7 天", + "3_days": "3 天", + "1_day": "1 天", + "12_hours": "12 小时", + "1_hour": "1 小时" + }, + "success": { + "title": "请求已创建!", + "description": "与需要提供秘密的人分享创建者链接", + "creator_link_label": "创建者链接", + "webhook_secret_label": "Webhook 密钥", + "webhook_secret_warning": "现在保存此密钥!它不会再次显示。使用它来验证 webhook 签名。", + "expires_at": "链接过期时间:{{date}}", + "create_another_button": "创建另一个请求", + "view_all_button": "查看所有请求" + }, + "toast": { + "created": "秘密请求创建成功", + "create_error": "创建秘密请求失败", + "copied": "已复制到剪贴板" + } + }, + "request_secret_page": { + "loading": "加载请求中...", + "error": { + "title": "请求不可用", + "invalid_link": "此链接无效或已被篡改。", + "not_found": "未找到此请求或链接无效。", + "already_fulfilled": "此请求已完成或已过期。", + "generic": "加载请求时发生错误。", + "go_home_button": "返回首页" + }, + "form": { + "title": "提交秘密", + "description": "有人请求您安全地与他们分享秘密", + "password_protected_note": "此秘密将受密码保护", + "encryption_note": "您的秘密将在浏览器中加密后再发送。解密密钥仅包含在您分享的最终 URL 中。", + "submitting_button": "加密并提交中...", + "submit_button": "提交秘密" + }, + "success": { + "title": "秘密已创建!", + "description": "您的秘密已加密并安全存储", + "decryption_key_label": "解密密钥", + "warning": "重要:现在复制此解密密钥并发送给请求者。这是您唯一一次看到它!", + "manual_send_note": "您必须手动将此解密密钥发送给请求秘密的人。他们的仪表板中已经有秘密 URL。", + "create_own_button": "创建您自己的秘密" + }, + "toast": { + "created": "秘密提交成功", + "create_error": "提交秘密失败", + "copied": "已复制到剪贴板" + } + } +} \ No newline at end of file diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..fba6995 --- /dev/null +++ b/src/index.css @@ -0,0 +1,90 @@ +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --color-dark-900: #0a0a0a; + --color-dark-800: #111111; + --color-dark-700: #1a1a1a; + --color-dark-600: #222222; + --color-dark-500: #2a2a2a; + + --color-light-900: #ffffff; + --color-light-800: #f8fafc; + --color-light-700: #f1f5f9; + --color-light-600: #e2e8f0; + --color-light-500: #cbd5e1; + + --breakpoint-xs: 475px; + + --animate-pulse: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@layer base { + html { + font-family: system-ui, sans-serif; + } + + body { + @apply bg-light-800 text-gray-900; + } + + .dark body { + @apply bg-dark-900 text-slate-100; + } +} + +@layer components { + .bg-grid-pattern { + background-image: radial-gradient( + circle at 1px 1px, + rgba(255, 255, 255, 0.15) 1px, + transparent 0 + ); + background-size: 20px 20px; + } + + .bg-grid-pattern-light { + background-image: radial-gradient(circle at 1px 1px, rgba(0, 0, 0, 0.1) 1px, transparent 0); + background-size: 20px 20px; + } + + .slider::-webkit-slider-thumb { + appearance: none; + height: 20px; + width: 20px; + border-radius: 50%; + background: #14b8a6; + cursor: pointer; + border: 2px solid #e2e8f0; + transition: all 0.3s ease; + } + + .dark .slider::-webkit-slider-thumb { + border: 2px solid #0f172a; + } + + .slider::-webkit-slider-thumb:hover { + transform: scale(1.1); + } + + .slider::-moz-range-thumb { + height: 20px; + width: 20px; + border-radius: 50%; + background: #14b8a6; + cursor: pointer; + border: 2px solid #e2e8f0; + } + + .dark .slider::-moz-range-thumb { + border: 2px solid #0f172a; + } +} + +@layer utilities { + .text-shadow { + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + } +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..3287c18 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,26 @@ +import { api } from './api'; + +const TRACKED_PATHS = ['/', '/secret']; + +export async function trackPageView(path: string): Promise { + // Only track specific paths + const shouldTrack = TRACKED_PATHS.some((trackedPath) => { + if (trackedPath === '/') { + return path === '/'; + } + return path.startsWith(trackedPath); + }); + + if (!shouldTrack) { + return; + } + + // Normalize secret paths to just /secret for privacy + const normalizedPath = path.startsWith('/secret/') ? '/secret' : path; + + try { + await api.analytics.track.$post({ json: { path: normalizedPath } }); + } catch { + // Silently fail - analytics should not affect user experience + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..4eefe3b --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,50 @@ +import { hc } from 'hono/client'; +import type { AppType } from '../../api/routes'; +import { useErrorStore } from '../store/errorStore'; + +interface ApiError { + error?: string | { issues?: { message: string }[]; name?: string; message?: string }; +} + +function extractErrorMessage(errorData: ApiError): string { + const { error } = errorData; + if (!error) return 'Unknown error'; + if (typeof error === 'string') return error; + + // Zod v4 ZodError serializes as { name: "ZodError", message: "[...JSON stringified issues...]" } + if (error.name === 'ZodError' && typeof error.message === 'string') { + try { + const issues = JSON.parse(error.message); + if (Array.isArray(issues)) { + return issues.map((i: { message: string }) => i.message).join(', '); + } + } catch { + // Fall through to other checks + } + } + + if (Array.isArray(error.issues)) { + return error.issues.map((i) => i.message).join(', '); + } + + return error.message || 'Unknown error'; +} + +const client = hc('/api', { + fetch(input, init) { + return fetch(input, init).then(async (res) => { + if (!res.ok) { + const errorData: ApiError = await res.json(); + const errorMessage = extractErrorMessage(errorData); + useErrorStore.getState().addError(errorMessage); + throw new Error(errorMessage); + } + return res; + }); + }, +}); + +// Raw client without error handling (for validation endpoints where errors are expected) +export const apiRaw = hc('/api'); + +export const api = client; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..e3e3658 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,14 @@ +import { adminClient, twoFactorClient } from 'better-auth/client/plugins'; +import { createAuthClient } from 'better-auth/react'; + +export const authClient = createAuthClient({ + baseURL: window.location.origin, + plugins: [ + twoFactorClient({ + onTwoFactorRedirect() { + window.location.href = '/verify-2fa'; + }, + }), + adminClient(), + ], +}); diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..904b6b5 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,181 @@ +import { nanoid } from 'nanoid'; + +/** + * Returns the user-provided password or generates a random 32-character string to be used as an encryption key. + * @param {string} [password] - The user's password. If not provided, a random key is generated. + * @returns {string} - The password or the generated key. + */ +export const generateEncryptionKey = (password?: string): string => { + if (password && password.length > 0) { + return password; + } + return nanoid(32); +}; + +/** + * Generates a random 256-bit (32-byte) salt. + * @returns {string} A 32-character string to be used as a salt. + */ +export const generateSalt = (): string => { + return nanoid(32); +}; + +/** + * Derives a 256-bit AES-GCM key from a user-provided string (password or generated key). + * Uses PBKDF2 for key derivation, which is more secure than simple hashing. + * @param {string} userKeyString - The user's password or generated key string. + * @param {string} salt - A unique identifier for the secret, used as a salt. + * @returns {Promise} - A promise that resolves to a CryptoKey. + */ +async function getDerivedKey(userKeyString: string, salt: string): Promise { + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(userKeyString), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ); + + return window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: new TextEncoder().encode(salt), + iterations: 1300000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypts data using AES-256-GCM. + * @param {string} text - The string data to encrypt. + * @param {string} userEncryptionKey - The user's password or generated key string. + * @param {string} salt - The salt to use for key derivation. + * @returns {Promise} - A promise that resolves to the encrypted data (IV + ciphertext). + */ +export const encrypt = async ( + text: string, + userEncryptionKey: string, + salt: string +): Promise => { + const key = await getDerivedKey(userEncryptionKey, salt); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV + const plaintext = new TextEncoder().encode(text); + + const ciphertext = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + plaintext + ); + + const fullMessage = new Uint8Array(iv.length + ciphertext.byteLength); + fullMessage.set(iv); + fullMessage.set(new Uint8Array(ciphertext), iv.length); + + return fullMessage; +}; + +/** + * Encrypts a file buffer using AES-256-GCM. + * @param {ArrayBuffer} fileBuffer - The file data to encrypt. + * @param {string} userEncryptionKey - The user's password or generated key string. + * @param {string} salt - The salt to use for key derivation. + * @returns {Promise} - A promise that resolves to the encrypted data (IV + ciphertext). + */ +export const encryptFile = async ( + fileBuffer: ArrayBuffer, + userEncryptionKey: string, + salt: string +): Promise => { + const key = await getDerivedKey(userEncryptionKey, salt); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV + const plaintext = new Uint8Array(fileBuffer); + + const ciphertext = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + plaintext + ); + + const fullMessage = new Uint8Array(iv.length + ciphertext.byteLength); + fullMessage.set(iv); + fullMessage.set(new Uint8Array(ciphertext), iv.length); + + return fullMessage; +}; + +/** + * Decrypts data using AES-256-GCM. + * @param {Uint8Array} fullMessage - The encrypted data (IV + ciphertext). + * @param {string} userEncryptionKey - The user's password or generated key string. + * @param {string} salt - The salt to use for key derivation. + * @returns {Promise} - A promise that resolves to the decrypted string data. + */ +export const decrypt = async ( + fullMessage: Uint8Array, + userEncryptionKey: string, + salt: string +): Promise => { + const key = await getDerivedKey(userEncryptionKey, salt); + const iv = fullMessage.slice(0, 12); + const ciphertext = fullMessage.slice(12); + + try { + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + ciphertext + ); + + return new TextDecoder().decode(decrypted); + } catch (e) { + console.error('Decryption failed!', e); + throw new Error('Could not decrypt message. The key may be wrong or the data corrupted.'); + } +}; + +/** + * Decrypts a file buffer using AES-256-GCM. + * @param {Uint8Array} fullMessage - The encrypted data (IV + ciphertext). + * @param {string} userEncryptionKey - The user's password or generated key string. + * @param {string} salt - The salt to use for key derivation. + * @returns {Promise} - A promise that resolves to the decrypted file data. + */ +export const decryptFile = async ( + fullMessage: Uint8Array, + userEncryptionKey: string, + salt: string +): Promise => { + const key = await getDerivedKey(userEncryptionKey, salt); + const iv = fullMessage.slice(0, 12); + const ciphertext = fullMessage.slice(12); + + try { + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + ciphertext + ); + + return new Uint8Array(decrypted); + } catch (e) { + console.error('Decryption failed!', e); + throw new Error('Could not decrypt message. The key may be wrong or the data corrupted.'); + } +}; diff --git a/src/lib/hash.ts b/src/lib/hash.ts new file mode 100644 index 0000000..70bb15a --- /dev/null +++ b/src/lib/hash.ts @@ -0,0 +1,9 @@ +export const hashString = (str: string): string => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return hash.toString(36); +}; diff --git a/src/logo.svg b/src/logo.svg new file mode 100644 index 0000000..031b1f4 --- /dev/null +++ b/src/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..5c370b4 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './i18n/i18n'; // Import the i18n configuration +import './index.css'; + +const root = createRoot(document.getElementById('root')!); + +if (import.meta.env.DEV) { + root.render( + + + + ); +} else { + root.render(); +} diff --git a/src/pages/Dashboard/Account/DangerZoneTab.tsx b/src/pages/Dashboard/Account/DangerZoneTab.tsx new file mode 100644 index 0000000..b06d682 --- /dev/null +++ b/src/pages/Dashboard/Account/DangerZoneTab.tsx @@ -0,0 +1,88 @@ +import { AlertTriangle, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { Modal } from '../../../components/Modal'; +import { api } from '../../../lib/api'; + +export function DangerZoneTab() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + const handleDeleteAccount = async () => { + setIsLoading(true); + try { + const res = await api.account.$delete(); + if (res.ok) { + navigate('/login'); + } else { + console.error('Failed to delete account'); + } + } catch (error) { + console.error('An error occurred', error); + } finally { + setIsLoading(false); + setIsDeleteModalOpen(false); + } + }; + + return ( + <> +
+
+
+ +
+
+

+ {t('account_page.danger_zone.title')} +

+

+ {t('account_page.danger_zone.description')} +

+
+
+ +
+

+ {t('account_page.danger_zone.delete_account_title')} +

+

+ {t('account_page.danger_zone.delete_account_description')} +

+
    +
  • • {t('account_page.danger_zone.delete_account_bullet1')}
  • +
  • • {t('account_page.danger_zone.delete_account_bullet2')}
  • +
  • • {t('account_page.danger_zone.delete_account_bullet3')}
  • +
  • • {t('account_page.danger_zone.delete_account_bullet4')}
  • +
+ +
+
+ + setIsDeleteModalOpen(false)} + onConfirm={handleDeleteAccount} + title={t('account_page.danger_zone.delete_account_title')} + confirmText={t('account_page.danger_zone.delete_account_button')} + cancelText={t('secrets_page.table.delete_cancel_button')} + > +

{t('account_page.danger_zone.delete_account_confirm')}

+
+ + ); +} diff --git a/src/pages/Dashboard/Account/DeveloperTab.tsx b/src/pages/Dashboard/Account/DeveloperTab.tsx new file mode 100644 index 0000000..0e17438 --- /dev/null +++ b/src/pages/Dashboard/Account/DeveloperTab.tsx @@ -0,0 +1,274 @@ +import { Check, Code, Copy, Key, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '../../../components/Modal'; +import { useCopyFeedbackWithId } from '../../../hooks/useCopyFeedback'; +import { api } from '../../../lib/api'; + +interface ApiKey { + id: string; + name: string; + keyPrefix: string; + lastUsedAt: string | null; + expiresAt: string | null; + createdAt: string; +} + +export function DeveloperTab() { + const { t } = useTranslation(); + const [apiKeys, setApiKeys] = useState([]); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyExpiry, setNewKeyExpiry] = useState(undefined); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [apiKeyError, setApiKeyError] = useState(''); + const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { copiedId, copy: handleCopyToClipboard } = useCopyFeedbackWithId(); + + const fetchApiKeys = async () => { + try { + const res = await api['api-keys'].$get(); + if (res.ok) { + const data = await res.json(); + setApiKeys(data); + } + } catch (error) { + console.error('Failed to fetch API keys:', error); + } + }; + + useEffect(() => { + fetchApiKeys(); + }, []); + + const handleCreateApiKey = async () => { + setApiKeyError(''); + if (!newKeyName.trim()) { + setApiKeyError(t('account_page.developer.name_required')); + return; + } + + setIsLoading(true); + try { + const res = await api['api-keys'].$post({ + json: { + name: newKeyName, + expiresInDays: newKeyExpiry, + }, + }); + if (res.ok) { + const data = await res.json(); + setNewlyCreatedKey(data.key); + setNewKeyName(''); + setNewKeyExpiry(undefined); + setIsCreateKeyModalOpen(false); + fetchApiKeys(); + } else { + const errorData = await res.json(); + setApiKeyError(errorData.error || t('account_page.developer.create_error')); + } + } catch (error) { + console.error('Failed to create API key:', error); + setApiKeyError(t('account_page.developer.create_error')); + } finally { + setIsLoading(false); + } + }; + + const handleDeleteApiKey = async (id: string) => { + try { + const res = await api['api-keys'][':id'].$delete({ param: { id } }); + if (res.ok) { + fetchApiKeys(); + } + } catch (error) { + console.error('Failed to delete API key:', error); + } + }; + + return ( + <> +
+
+
+
+ +
+
+

+ {t('account_page.developer.title')} +

+

+ {t('account_page.developer.description')} +

+
+
+ +
+ + {newlyCreatedKey && ( +
+

+ {t('account_page.developer.key_created')} +

+

+ {t('account_page.developer.key_warning')} +

+
+ + {newlyCreatedKey} + + +
+ +
+ )} + +
+ {apiKeys.length === 0 ? ( +
+ +

{t('account_page.developer.no_keys')}

+
+ ) : ( + apiKeys.map((apiKey) => ( +
+
+
+

+ {apiKey.name} +

+

+ {apiKey.keyPrefix}... +

+
+ +
+
+ + {t('account_page.developer.created')}:{' '} + {new Date(apiKey.createdAt).toLocaleDateString()} + + {apiKey.lastUsedAt && ( + + {t('account_page.developer.last_used')}:{' '} + {new Date(apiKey.lastUsedAt).toLocaleDateString()} + + )} + {apiKey.expiresAt && ( + + {t('account_page.developer.expires')}:{' '} + {new Date(apiKey.expiresAt).toLocaleDateString()} + + )} +
+
+ )) + )} +
+ +
+

+ {t('account_page.developer.docs_hint')}{' '} + + {t('account_page.developer.api_docs')} + +

+
+
+ + { + setIsCreateKeyModalOpen(false); + setNewKeyName(''); + setNewKeyExpiry(undefined); + setApiKeyError(''); + }} + onConfirm={handleCreateApiKey} + title={t('account_page.developer.create_key_title')} + confirmText={t('account_page.developer.create_button')} + cancelText={t('secrets_page.table.delete_cancel_button')} + > +
+
+ + setNewKeyName(e.target.value)} + placeholder={t('account_page.developer.key_name_placeholder')} + className="w-full px-3 py-2 bg-gray-100 dark:bg-dark-700/50 border border-gray-300 dark:border-dark-500/50 text-gray-900 dark:text-slate-100 placeholder-gray-400 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50 transition-all duration-300" + /> +
+
+ + +
+ {apiKeyError &&

{apiKeyError}

} +
+
+ + ); +} diff --git a/src/pages/Dashboard/Account/ProfileTab.tsx b/src/pages/Dashboard/Account/ProfileTab.tsx new file mode 100644 index 0000000..9968493 --- /dev/null +++ b/src/pages/Dashboard/Account/ProfileTab.tsx @@ -0,0 +1,107 @@ +import { Mail, Save, User } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api } from '../../../lib/api'; +import { useAccountStore } from '../../../store/accountStore'; + +export function ProfileTab() { + const { t } = useTranslation(); + const { profileData, setProfileData } = useAccountStore(); + const [isLoading, setIsLoading] = useState(false); + const [profileError, setProfileError] = useState(''); + + const handleProfileChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setProfileData({ ...profileData, [name]: value }); + }; + + const handleProfileSave = async () => { + setIsLoading(true); + setProfileError(''); + try { + const res = await api.account.$put({ json: profileData }); + if (res.ok) { + const updatedData = await res.json(); + setProfileData(updatedData); + } else if (res.status === 409) { + setProfileError(t('account_page.profile_settings.username_taken')); + } else { + console.error('Failed to update profile'); + } + } catch (error) { + console.error('An error occurred', error); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ +
+
+

+ {t('account_page.profile_info.title')} +

+

+ {t('account_page.profile_info.description')} +

+
+
+ +
+
+ + +
+ +
+ +
+ + +
+
+ + {profileError && ( +
+ {profileError} +
+ )} + +
+ +
+
+
+ ); +} diff --git a/src/pages/Dashboard/Account/SecurityTab.tsx b/src/pages/Dashboard/Account/SecurityTab.tsx new file mode 100644 index 0000000..0860e25 --- /dev/null +++ b/src/pages/Dashboard/Account/SecurityTab.tsx @@ -0,0 +1,595 @@ +import { Check, Eye, EyeOff, Key, Shield, Smartphone } from 'lucide-react'; +import { QRCodeSVG } from 'qrcode.react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal } from '../../../components/Modal'; +import { api } from '../../../lib/api'; +import { authClient } from '../../../lib/auth'; + +interface SecurityTabProps { + initialTwoFactorEnabled: boolean; +} + +export function SecurityTab({ initialTwoFactorEnabled }: SecurityTabProps) { + const { t } = useTranslation(); + const [showCurrentPassword, setShowCurrentPassword] = useState(false); + const [showNewPassword, setShowNewPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const [passwordData, setPasswordData] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + const [passwordErrors, setPasswordErrors] = useState<{ [key: string]: string }>({}); + const [successMessage, setSuccessMessage] = useState(''); + + // 2FA state + const [twoFactorEnabled, setTwoFactorEnabled] = useState(initialTwoFactorEnabled); + const [totpUri, setTotpUri] = useState(null); + const [backupCodes, setBackupCodes] = useState([]); + const [show2FASetup, setShow2FASetup] = useState(false); + const [twoFAPassword, setTwoFAPassword] = useState(''); + const [twoFAVerifyCode, setTwoFAVerifyCode] = useState(''); + const [twoFAError, setTwoFAError] = useState(''); + const [twoFAStep, setTwoFAStep] = useState<'password' | 'qr' | 'verify'>('password'); + const [isDisable2FAModalOpen, setIsDisable2FAModalOpen] = useState(false); + const [disable2FAPassword, setDisable2FAPassword] = useState(''); + const [showBackupCodesModal, setShowBackupCodesModal] = useState(false); + + const handlePasswordChange = async () => { + setSuccessMessage(''); + setPasswordErrors({}); + + if (passwordData.newPassword !== passwordData.confirmPassword) { + setPasswordErrors({ + confirmPassword: t('account_page.security_settings.password_mismatch_alert'), + }); + return; + } + + setIsLoading(true); + try { + const res = await api.account.password.$put({ json: passwordData }); + if (res.ok) { + setSuccessMessage(t('account_page.security_settings.password_change_success')); + setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' }); + } else { + const errorData = await res.json(); + if (errorData.error && errorData.error.issues) { + const newErrors: { [key: string]: string } = {}; + errorData.error.issues.forEach( + (issue: { path: (string | number)[]; message: string }) => { + if (issue.path && issue.path.length > 0) { + newErrors[issue.path[0]] = issue.message; + } + } + ); + setPasswordErrors(newErrors); + } else { + setPasswordErrors({ + form: + errorData.error || + t('account_page.security_settings.password_change_error'), + }); + } + } + } catch (error) { + console.error('An error occurred', error); + setPasswordErrors({ + form: + error instanceof Error && error.message !== 'Unknown error' + ? error.message + : t('account_page.security_settings.password_change_error'), + }); + } finally { + setIsLoading(false); + } + }; + + const handleEnable2FA = async () => { + setTwoFAError(''); + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.enable({ + password: twoFAPassword, + }); + + if (error) { + setTwoFAError(error.message || t('account_page.two_factor.invalid_password')); + return; + } + + if (data?.totpURI) { + setTotpUri(data.totpURI); + setBackupCodes(data.backupCodes || []); + setTwoFAStep('qr'); + } + } catch (error) { + console.error('Failed to enable 2FA:', error); + setTwoFAError(t('account_page.two_factor.enable_error')); + } finally { + setIsLoading(false); + } + }; + + const handleVerify2FA = async () => { + setTwoFAError(''); + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.verifyTotp({ + code: twoFAVerifyCode, + }); + + if (error) { + setTwoFAError(t('account_page.two_factor.invalid_code')); + return; + } + + setTwoFactorEnabled(true); + setShow2FASetup(false); + setShowBackupCodesModal(true); + reset2FAState(); + } catch (error) { + console.error('Failed to verify 2FA:', error); + setTwoFAError(t('account_page.two_factor.verify_error')); + } finally { + setIsLoading(false); + } + }; + + const handleDisable2FA = async () => { + setTwoFAError(''); + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.disable({ + password: disable2FAPassword, + }); + + if (error) { + setTwoFAError(t('account_page.two_factor.invalid_password')); + return; + } + + setTwoFactorEnabled(false); + setIsDisable2FAModalOpen(false); + setDisable2FAPassword(''); + } catch (error) { + console.error('Failed to disable 2FA:', error); + setTwoFAError(t('account_page.two_factor.disable_error')); + } finally { + setIsLoading(false); + } + }; + + const reset2FAState = () => { + setTwoFAPassword(''); + setTwoFAVerifyCode(''); + setTotpUri(null); + setTwoFAStep('password'); + setTwoFAError(''); + }; + + return ( + <> +
+
+
+ +
+
+

+ {t('account_page.security_settings.title')} +

+

+ {t('account_page.security_settings.description')} +

+
+
+ +
+ {/* Password Change Section */} +
+

+ {t('account_page.security_settings.change_password_title')} +

+
+ + setPasswordData((prev) => ({ ...prev, currentPassword: value })) + } + show={showCurrentPassword} + onToggle={() => setShowCurrentPassword(!showCurrentPassword)} + placeholder={t( + 'account_page.security_settings.current_password_placeholder' + )} + error={passwordErrors.currentPassword} + /> + + + setPasswordData((prev) => ({ ...prev, newPassword: value })) + } + show={showNewPassword} + onToggle={() => setShowNewPassword(!showNewPassword)} + placeholder={t( + 'account_page.security_settings.new_password_placeholder' + )} + error={passwordErrors.newPassword} + /> + + + setPasswordData((prev) => ({ ...prev, confirmPassword: value })) + } + show={showConfirmPassword} + onToggle={() => setShowConfirmPassword(!showConfirmPassword)} + placeholder={t( + 'account_page.security_settings.confirm_new_password_placeholder' + )} + error={passwordErrors.confirmPassword} + /> + + {passwordErrors.form && ( +

{passwordErrors.form}

+ )} + {successMessage && ( +

{successMessage}

+ )} + + +
+
+ + {/* Two-Factor Authentication Section */} +
+
+
+

+ {t('account_page.two_factor.title')} +

+

+ {t('account_page.two_factor.description')} +

+
+ {twoFactorEnabled ? ( + + + {t('account_page.two_factor.enabled')} + + ) : ( + + {t('account_page.two_factor.disabled')} + + )} +
+ + {!show2FASetup ? ( +
+ {!twoFactorEnabled ? ( + + ) : ( + + )} +
+ ) : ( + { + setShow2FASetup(false); + reset2FAState(); + }} + onBack={() => setTwoFAStep('qr')} + onContinue={() => setTwoFAStep('verify')} + t={t} + /> + )} +
+
+
+ + {/* Disable 2FA Modal */} + { + setIsDisable2FAModalOpen(false); + setDisable2FAPassword(''); + setTwoFAError(''); + }} + onConfirm={handleDisable2FA} + title={t('account_page.two_factor.disable_title')} + confirmText={t('account_page.two_factor.disable_button')} + cancelText={t('common.cancel')} + confirmButtonClass="bg-red-500 hover:bg-red-600" + > +
+

+ {t('account_page.two_factor.disable_warning')} +

+
+ + setDisable2FAPassword(e.target.value)} + className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + placeholder={t( + 'account_page.security_settings.current_password_placeholder' + )} + /> +
+ {twoFAError &&

{twoFAError}

} +
+
+ + {/* Backup Codes Modal */} + setShowBackupCodesModal(false)} + onConfirm={() => setShowBackupCodesModal(false)} + title={t('account_page.two_factor.backup_codes_title')} + confirmText={t('account_page.two_factor.backup_codes_saved')} + > +
+

+ {t('account_page.two_factor.backup_codes_description')} +

+
+ {backupCodes.map((code, index) => ( + + {code} + + ))} +
+

+ {t('account_page.two_factor.backup_codes_warning')} +

+
+
+ + ); +} + +// Helper component for password inputs +function PasswordInput({ + label, + value, + onChange, + show, + onToggle, + placeholder, + error, +}: { + label: string; + value: string; + onChange: (value: string) => void; + show: boolean; + onToggle: () => void; + placeholder: string; + error?: string; +}) { + return ( +
+ +
+ + onChange(e.target.value)} + className={`w-full pl-9 pr-9 py-2 text-sm bg-gray-50 dark:bg-dark-700 border text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 transition-colors ${error ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : 'border-gray-200 dark:border-dark-600 focus:ring-teal-500 focus:border-teal-500'}`} + placeholder={placeholder} + /> + +
+ {error &&

{error}

} +
+ ); +} + +// Helper component for 2FA setup flow +function TwoFASetup({ + step, + password, + setPassword, + verifyCode, + setVerifyCode, + totpUri, + error, + isLoading, + onEnable, + onVerify, + onCancel, + onBack, + onContinue, + t, +}: { + step: 'password' | 'qr' | 'verify'; + password: string; + setPassword: (v: string) => void; + verifyCode: string; + setVerifyCode: (v: string) => void; + totpUri: string | null; + error: string; + isLoading: boolean; + onEnable: () => void; + onVerify: () => void; + onCancel: () => void; + onBack: () => void; + onContinue: () => void; + t: (key: string) => string; +}) { + return ( +
+ {step === 'password' && ( +
+

+ {t('account_page.two_factor.enter_password_to_enable')} +

+
+ +
+ + setPassword(e.target.value)} + className="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-dark-800 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors" + placeholder={t( + 'account_page.security_settings.current_password_placeholder' + )} + /> +
+
+ {error &&

{error}

} +
+ + +
+
+ )} + + {step === 'qr' && totpUri && ( +
+

+ {t('account_page.two_factor.scan_qr_code')} +

+
+ +
+

+ {t('account_page.two_factor.manual_entry_hint')} +

+
+ {totpUri} +
+ +
+ )} + + {step === 'verify' && ( +
+

+ {t('account_page.two_factor.enter_verification_code')} +

+
+ + + setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6)) + } + className="w-full px-3 py-2 text-sm bg-white dark:bg-dark-800 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500 focus:border-teal-500 transition-colors text-center text-lg tracking-widest" + placeholder="000000" + maxLength={6} + /> +
+ {error &&

{error}

} +
+ + +
+
+ )} +
+ ); +} diff --git a/src/pages/Dashboard/Account/index.ts b/src/pages/Dashboard/Account/index.ts new file mode 100644 index 0000000..810fd85 --- /dev/null +++ b/src/pages/Dashboard/Account/index.ts @@ -0,0 +1,4 @@ +export { DangerZoneTab } from './DangerZoneTab'; +export { DeveloperTab } from './DeveloperTab'; +export { ProfileTab } from './ProfileTab'; +export { SecurityTab } from './SecurityTab'; diff --git a/src/pages/Dashboard/AccountPage.tsx b/src/pages/Dashboard/AccountPage.tsx new file mode 100644 index 0000000..8cf296c --- /dev/null +++ b/src/pages/Dashboard/AccountPage.tsx @@ -0,0 +1,88 @@ +import { AlertTriangle, Code, Shield, User } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLoaderData } from 'react-router-dom'; +import { useAccountStore } from '../../store/accountStore'; +import { DangerZoneTab, DeveloperTab, ProfileTab, SecurityTab } from './Account'; + +export function AccountPage() { + const { t } = useTranslation(); + const { setProfileData } = useAccountStore(); + const initialData = useLoaderData() as { + username: string; + email: string; + twoFactorEnabled: boolean; + }; + + const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'developer' | 'danger'>( + 'profile' + ); + + useEffect(() => { + setProfileData({ username: initialData.username, email: initialData.email }); + }, [initialData, setProfileData]); + + const tabs = [ + { id: 'profile', name: t('account_page.tabs.profile'), icon: User }, + { id: 'security', name: t('account_page.tabs.security'), icon: Shield }, + { id: 'developer', name: t('account_page.tabs.developer'), icon: Code }, + { id: 'danger', name: t('account_page.tabs.danger_zone'), icon: AlertTriangle }, + ]; + + return ( +
+ {/* Header */} +
+

+ {t('account_page.title')} +

+

+ {t('account_page.description')} +

+
+ + {/* Tabs */} +
+
+ +
+
+ + {/* Tab Content */} +
+ {activeTab === 'profile' && } + {activeTab === 'security' && ( + + )} + {activeTab === 'developer' && } + {activeTab === 'danger' && } +
+
+ ); +} diff --git a/src/pages/Dashboard/AnalyticsPage.tsx b/src/pages/Dashboard/AnalyticsPage.tsx new file mode 100644 index 0000000..5a5712c --- /dev/null +++ b/src/pages/Dashboard/AnalyticsPage.tsx @@ -0,0 +1,785 @@ +import { + BarChart3, + Calendar, + CheckCircle, + Clock, + Eye, + Globe, + MessageSquare, + Shield, + TrendingDown, + TrendingUp, + Users, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useLoaderData } from 'react-router-dom'; +import { toast } from 'sonner'; +import { Sparkline } from '../../components/Sparkline'; +import { api } from '../../lib/api'; + +interface DailyVisitor { + date: string; + unique_visitors: number; + total_visits: number; + paths: string; +} + +interface DailyStat { + date: string; + secrets: number; + views: number; +} + +interface SecretTypes { + passwordProtected: number; + ipRestricted: number; + burnable: number; +} + +interface ExpirationStats { + oneHour: number; + oneDay: number; + oneWeekPlus: number; +} + +interface SecretRequestStats { + total: number; + fulfilled: number; +} + +interface AnalyticsData { + totalSecrets: number; + totalViews: number; + averageViews: number; + activeSecrets: number; + expiredSecrets: number; + dailyStats: DailyStat[]; + secretTypes: SecretTypes; + expirationStats: ExpirationStats; + secretRequests: SecretRequestStats; +} + +interface AnalyticsLoaderData { + error?: string; + totalSecrets?: number; + totalViews?: number; + averageViews?: number; + activeSecrets?: number; + expiredSecrets?: number; + dailyStats?: DailyStat[]; + secretTypes?: SecretTypes; + expirationStats?: ExpirationStats; + secretRequests?: SecretRequestStats; + visitorStats?: DailyVisitor[]; +} + +type TimeRange = '7d' | '14d' | '30d'; + +export function AnalyticsPage() { + const initialAnalytics = useLoaderData() as AnalyticsLoaderData; + const [timeRange, setTimeRange] = useState('30d'); + const { t } = useTranslation(); + const [analytics, setAnalytics] = useState( + initialAnalytics.error ? null : (initialAnalytics as AnalyticsData) + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(initialAnalytics.error || null); + const [visitorStats, setVisitorStats] = useState( + initialAnalytics.visitorStats || [] + ); + + const fetchAnalytics = async (range: TimeRange) => { + setLoading(true); + try { + const [analyticsRes, visitorRes] = await Promise.all([ + api.analytics.$get({ query: { timeRange: range } }), + api.analytics.visitors.daily.$get({ query: { timeRange: range } }), + ]); + if (analyticsRes.status === 403) { + toast.error(t('analytics_page.no_permission')); + setAnalytics(null); + setError(t('analytics_page.no_permission')); + return; + } + if (!analyticsRes.ok) throw new Error('Failed to fetch'); + const data = await analyticsRes.json(); + setAnalytics(data as AnalyticsData); + + if (visitorRes.ok) { + const visitorData = await visitorRes.json(); + setVisitorStats(visitorData as DailyVisitor[]); + } + + setError(null); + } catch { + toast.error(t('analytics_page.failed_to_fetch')); + setError(t('analytics_page.failed_to_fetch')); + } finally { + setLoading(false); + } + }; + + const handleTimeRangeChange = (e: React.ChangeEvent) => { + const newTimeRange = e.target.value as TimeRange; + setTimeRange(newTimeRange); + fetchAnalytics(newTimeRange); + }; + + const totalUniqueVisitors = visitorStats.reduce((acc, day) => acc + day.unique_visitors, 0); + const totalPageViews = visitorStats.reduce((acc, day) => acc + day.total_visits, 0); + + const timeRangeOptions = [ + { value: '7d', label: t('analytics_page.time_range.last_7_days') }, + { value: '14d', label: t('analytics_page.time_range.last_14_days') }, + { value: '30d', label: t('analytics_page.time_range.last_30_days') }, + ]; + + if (error) { + return ( +
+

Error

+

{error}

+
+ ); + } + + if (loading) { + return
{t('analytics_page.loading')}
; + } + + if (!analytics) { + return
Could not load analytics.
; + } + + return ( +
+ {/* Header */} +
+
+
+

+ {t('analytics_page.title')} +

+

+ {t('analytics_page.description')} +

+
+
+ + +
+
+
+ + {/* Key Metrics */} +
+
+
+
+ +
+
+

+ {analytics.totalSecrets} +

+

+ {t('analytics_page.total_secrets')} +

+
+
+
+ +
+
+
+ +
+
+

+ {analytics.totalViews.toLocaleString()} +

+

+ {t('analytics_page.total_views')} +

+
+
+
+ +
+
+
+ +
+
+

+ {analytics.averageViews} +

+

+ {t('analytics_page.avg_views_per_secret')} +

+
+
+
+ +
+
+
+ +
+
+

+ {analytics.activeSecrets} +

+

+ {t('analytics_page.active_secrets')} +

+
+
+
+
+ + {/* Secret Requests Metrics */} +
+
+
+
+ +
+
+

+ {analytics.secretRequests?.total ?? 0} +

+

+ {t('analytics_page.secret_requests.total')} +

+
+
+
+ +
+
+
+ +
+
+

+ {analytics.secretRequests?.fulfilled ?? 0} +

+

+ {t('analytics_page.secret_requests.fulfilled')} +

+
+
+
+
+ + {/* Activity Chart */} +
+
+
+ +
+
+

+ {t('analytics_page.daily_activity.title')} +

+

+ {t('analytics_page.daily_activity.description')} +

+
+
+ + {analytics.dailyStats.length === 0 ? ( +
+ {t('analytics_page.daily_activity.no_data')} +
+ ) : ( + <> + {/* Summary Cards with Sparklines */} +
+
+
+
+

+ {t('analytics_page.daily_activity.secrets_created')} +

+

+ {analytics.dailyStats + .reduce((acc, d) => acc + d.secrets, 0) + .toLocaleString()} +

+ {analytics.dailyStats.length >= 2 && ( +
+ {analytics.dailyStats[ + analytics.dailyStats.length - 1 + ].secrets >= + analytics.dailyStats[ + analytics.dailyStats.length - 2 + ].secrets ? ( + + ) : ( + + )} + + {t('analytics_page.daily_activity.vs_previous')} + +
+ )} +
+ d.secrets)} + width={80} + height={32} + color="#14b8a6" + className="text-teal-500" + /> +
+
+
+
+
+

+ {t('analytics_page.daily_activity.secret_views')} +

+

+ {analytics.dailyStats + .reduce((acc, d) => acc + d.views, 0) + .toLocaleString()} +

+ {analytics.dailyStats.length >= 2 && ( +
+ {analytics.dailyStats[ + analytics.dailyStats.length - 1 + ].views >= + analytics.dailyStats[ + analytics.dailyStats.length - 2 + ].views ? ( + + ) : ( + + )} + + {t('analytics_page.daily_activity.vs_previous')} + +
+ )} +
+ d.views)} + width={80} + height={32} + color="#3b82f6" + className="text-blue-500" + /> +
+
+
+ + {/* Compact Table */} +
+ + + + + + + + + + + {[...analytics.dailyStats].reverse().map((day, index, arr) => { + const prevDay = arr[index + 1]; + const secretsChange = prevDay + ? day.secrets - prevDay.secrets + : 0; + return ( + + + + + + + ); + })} + +
+ {t('analytics_page.daily_activity.date')} + +
+ + + {t('analytics_page.daily_activity.secrets')} + +
+
+
+ + + {t('analytics_page.daily_activity.views')} + +
+
+ {t('analytics_page.daily_activity.trend')} +
+ {new Date(day.date).toLocaleDateString( + t('analytics_page.locale'), + { + weekday: 'short', + month: 'short', + day: 'numeric', + } + )} + + {day.secrets.toLocaleString()} + + {day.views.toLocaleString()} + + {prevDay && ( + 0 ? 'text-emerald-500' : secretsChange < 0 ? 'text-red-500' : 'text-gray-400'}`} + > + {secretsChange > 0 ? '+' : ''} + {secretsChange} + + )} +
+
+ + )} +
+ + {/* Additional Stats */} +
+
+
+
+ +
+
+

+ {t('analytics_page.secret_types.title')} +

+

+ {t('analytics_page.secret_types.description')} +

+
+
+
+
+ + {t('analytics_page.secret_types.password_protected')} + +
+
+
+
+ + {analytics.secretTypes.passwordProtected}% + +
+
+
+ + {t('analytics_page.secret_types.ip_restricted')} + +
+
+
+
+ + {analytics.secretTypes.ipRestricted}% + +
+
+
+ + {t('analytics_page.secret_types.burn_after_time')} + +
+
+
+
+ + {analytics.secretTypes.burnable}% + +
+
+
+
+ +
+
+
+ +
+
+

+ {t('analytics_page.expiration_stats.title')} +

+

+ {t('analytics_page.expiration_stats.description')} +

+
+
+
+
+ + {t('analytics_page.expiration_stats.one_hour')} + +
+
+
+
+ + {analytics.expirationStats.oneHour}% + +
+
+
+ + {t('analytics_page.expiration_stats.one_day')} + +
+
+
+
+ + {analytics.expirationStats.oneDay}% + +
+
+
+ + {t('analytics_page.expiration_stats.one_week_plus')} + +
+
+
+
+ + {analytics.expirationStats.oneWeekPlus}% + +
+
+
+
+
+ + {/* Visitor Analytics Section */} +
+
+
+
+ +
+
+

+ {t('analytics_page.visitor_analytics.title')} +

+

+ {t('analytics_page.visitor_analytics.description')} +

+
+
+ + {visitorStats.length === 0 ? ( +
+ {t('analytics_page.visitor_analytics.no_data')} +
+ ) : ( + <> + {/* Summary Cards with Sparklines */} +
+
+
+
+

+ {t('analytics_page.visitor_analytics.unique')} +

+

+ {totalUniqueVisitors.toLocaleString()} +

+ {visitorStats.length >= 2 && ( +
+ {visitorStats[visitorStats.length - 1] + .unique_visitors >= + visitorStats[visitorStats.length - 2] + .unique_visitors ? ( + + ) : ( + + )} + + {t( + 'analytics_page.visitor_analytics.vs_previous' + )} + +
+ )} +
+ d.unique_visitors)} + width={80} + height={32} + color="#6366f1" + className="text-indigo-500" + /> +
+
+
+
+
+

+ {t('analytics_page.visitor_analytics.views')} +

+

+ {totalPageViews.toLocaleString()} +

+ {visitorStats.length >= 2 && ( +
+ {visitorStats[visitorStats.length - 1] + .total_visits >= + visitorStats[visitorStats.length - 2] + .total_visits ? ( + + ) : ( + + )} + + {t( + 'analytics_page.visitor_analytics.vs_previous' + )} + +
+ )} +
+ d.total_visits)} + width={80} + height={32} + color="#10b981" + className="text-emerald-500" + /> +
+
+
+ + {/* Compact Table */} +
+ + + + + + + + + + + {[...visitorStats].reverse().map((day, index, arr) => { + const prevDay = arr[index + 1]; + const change = prevDay + ? day.unique_visitors - prevDay.unique_visitors + : 0; + return ( + + + + + + + ); + })} + +
+ {t('analytics_page.visitor_analytics.date')} + +
+ + + {t( + 'analytics_page.visitor_analytics.unique' + )} + +
+
+
+ + + {t( + 'analytics_page.visitor_analytics.views' + )} + +
+
+ {t('analytics_page.visitor_analytics.trend')} +
+ {new Date(day.date).toLocaleDateString( + t('analytics_page.locale'), + { + weekday: 'short', + month: 'short', + day: 'numeric', + } + )} + + {day.unique_visitors.toLocaleString()} + + {day.total_visits.toLocaleString()} + + {prevDay && ( + 0 ? 'text-emerald-500' : change < 0 ? 'text-red-500' : 'text-gray-400'}`} + > + {change > 0 ? '+' : ''} + {change} + + )} +
+
+ + )} +
+
+
+ ); +} diff --git a/src/pages/Dashboard/CreateSecretRequestPage.tsx b/src/pages/Dashboard/CreateSecretRequestPage.tsx new file mode 100644 index 0000000..0ea3c14 --- /dev/null +++ b/src/pages/Dashboard/CreateSecretRequestPage.tsx @@ -0,0 +1,394 @@ +import { ArrowLeft, Copy, Link2 } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { api } from '../../lib/api'; +import { copyToClipboard as copyText } from '../../utils/clipboard'; + +// Valid expiration times for the secret (in seconds) +const SECRET_EXPIRATION_OPTIONS = [ + { value: 2419200, labelKey: 'expiration.28_days' }, + { value: 1209600, labelKey: 'expiration.14_days' }, + { value: 604800, labelKey: 'expiration.7_days' }, + { value: 259200, labelKey: 'expiration.3_days' }, + { value: 86400, labelKey: 'expiration.1_day' }, + { value: 43200, labelKey: 'expiration.12_hours' }, + { value: 14400, labelKey: 'expiration.4_hours' }, + { value: 3600, labelKey: 'expiration.1_hour' }, + { value: 1800, labelKey: 'expiration.30_minutes' }, + { value: 300, labelKey: 'expiration.5_minutes' }, +]; + +// Valid durations for request validity (how long the creator link is active) +const REQUEST_VALIDITY_OPTIONS = [ + { value: 2592000, labelKey: 'create_request_page.validity.30_days' }, + { value: 1209600, labelKey: 'create_request_page.validity.14_days' }, + { value: 604800, labelKey: 'create_request_page.validity.7_days' }, + { value: 259200, labelKey: 'create_request_page.validity.3_days' }, + { value: 86400, labelKey: 'create_request_page.validity.1_day' }, + { value: 43200, labelKey: 'create_request_page.validity.12_hours' }, + { value: 3600, labelKey: 'create_request_page.validity.1_hour' }, +]; + +interface CreatedRequest { + id: string; + creatorLink: string; + webhookSecret?: string; + expiresAt: string; +} + +export function CreateSecretRequestPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [maxViews, setMaxViews] = useState(1); + const [expiresIn, setExpiresIn] = useState(86400); // 1 day default for secret + const [validFor, setValidFor] = useState(604800); // 7 days default for link + const [allowedIp, setAllowedIp] = useState(''); + const [preventBurn, setPreventBurn] = useState(false); + const [webhookUrl, setWebhookUrl] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + const [createdRequest, setCreatedRequest] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const res = await api['secret-requests'].$post({ + json: { + title, + description: description || undefined, + maxViews, + expiresIn, + validFor, + allowedIp: allowedIp || undefined, + preventBurn, + webhookUrl: webhookUrl || undefined, + }, + }); + + if (res.ok) { + const data = await res.json(); + setCreatedRequest(data); + toast.success(t('create_request_page.toast.created')); + } else { + const error = await res.json(); + toast.error(error.error || t('create_request_page.toast.create_error')); + } + } catch (error) { + console.error('Failed to create request:', error); + toast.error(t('create_request_page.toast.create_error')); + } finally { + setIsLoading(false); + } + }; + + const handleCopyToClipboard = async (text: string) => { + const success = await copyText(text); + if (success) { + toast.success(t('create_request_page.toast.copied')); + } + }; + + if (createdRequest) { + return ( +
+
+
+
+
+ +
+
+

+ {t('create_request_page.success.title')} +

+

+ {t('create_request_page.success.description')} +

+
+
+ +
+
+ +
+
+ + {createdRequest.creatorLink} + +
+ +
+
+ + {createdRequest.webhookSecret && ( +
+ +
+
+ + {createdRequest.webhookSecret} + +
+ +
+

+ {t('create_request_page.success.webhook_secret_warning')} +

+
+ )} + +

+ {t('create_request_page.success.expires_at', { + date: new Date(createdRequest.expiresAt).toLocaleString(), + })} +

+ +
+ + + {t('create_request_page.success.view_all_button')} + +
+
+
+
+
+ ); + } + + return ( +
+
+
+ + + {t('create_request_page.back_button')} + +
+ +
+

+ {t('create_request_page.title')} +

+

+ {t('create_request_page.description')} +

+ +
+ {/* Title */} +
+ + setTitle(e.target.value)} + placeholder={t('create_request_page.form.title_placeholder')} + required + maxLength={200} + className="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-dark-700 border border-gray-200 dark:border-dark-600 text-gray-900 dark:text-slate-100 focus:outline-none focus:ring-1 focus:ring-teal-500" + /> +
+ + {/* Description */} +
+ +