commit 1773ed5f9564aef23e65d3283d7a0a627f42559e Author: paul Date: Fri Jul 18 19:25:15 2025 +0200 Initial commit - Project start (July 17, 2025) Original: feat: enhance security logging and ensure rate limit blocks are properly tracked - Add comprehensive logging for rate limit blocks with full request details - IP address (with proper proxy detection), user agent, headers, timestamps - Rate limit info (current count, limit, remaining, reset time) - Separate tracking for auth vs general endpoints - Enhance authentication failure logging - JWT validation failures with detailed error info - Admin auth attempts without token - Failed token validation with user context - All events include IP, path, method, user agent - Improve Winston logger configuration for production - Add automatic log rotation (10MB errors, 50MB combined) - Create separate security.log for auth/rate limit events - Ensure logs directory exists automatically - Add structured JSON format for log aggregation - Support container logging with LOG_TO_CONSOLE env var - Create comprehensive documentation - Security logging guide with examples - Monitoring recommendations - Configuration reference - Add test script to verify logging functionality All rate limit settings remain configurable via admin panel: - Window duration, max requests, auth limits - Skip authenticated requests option - Public endpoints only option ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude diff --git a/.claudedocs/scans/security-20250713.md b/.claudedocs/scans/security-20250713.md new file mode 100644 index 0000000..fce1bb6 --- /dev/null +++ b/.claudedocs/scans/security-20250713.md @@ -0,0 +1,286 @@ +# Security Scan Report - Wedding Photo Sharing Application +**Date**: July 13, 2025 +**Scanner**: Claude Security Audit with --security --validate flags +**Overall Risk Level**: MEDIUM-HIGH + +## Executive Summary + +The wedding photo sharing application demonstrates strong security fundamentals with comprehensive input validation, proper authentication mechanisms, and good file security practices. However, several critical issues require immediate attention, particularly around hardcoded secrets, token storage, and Content Security Policy configuration. + +### Security Score: 6.5/10 + +**Strengths**: Excellent input validation, parameterized queries, file security, rate limiting +**Critical Issues**: Hardcoded JWT secrets, localStorage token storage, weak CSP, console logging in production + +--- + +## ๐Ÿ”ด CRITICAL FINDINGS (Immediate Action Required) + +### 1. Hardcoded JWT Secret in Development +- **Location**: Backend `.env` file +- **Risk**: Token forgery, authentication bypass +- **Impact**: Complete authentication compromise +- **Remediation**: + ```bash + # Generate secure secret + openssl rand -base64 32 + # Never commit to repository + echo ".env" >> .gitignore + ``` + +### 2. Gallery Tokens in localStorage +- **Location**: Frontend `api.ts` and auth contexts +- **Risk**: XSS token theft +- **Impact**: Gallery access compromise +- **Remediation**: Move to httpOnly cookies: + ```typescript + Cookies.set(`gallery_token_${slug}`, token, { + httpOnly: true, + secure: true, + sameSite: 'strict' + }); + ``` + +### 3. Weak Content Security Policy +- **Location**: Frontend `nginx.conf` +- **Risk**: XSS, code injection +- **Current**: `unsafe-inline` and `unsafe-eval` allowed +- **Remediation**: Implement strict CSP (see detailed recommendations below) + +--- + +## ๐ŸŸ  HIGH SEVERITY FINDINGS + +### 1. Console Logging in Production +- **Locations**: 61 instances across frontend +- **Risk**: Information disclosure +- **Impact**: Leaking sensitive data, debugging info +- **Remediation**: Implement environment-aware logging + +### 2. Token Revocation Vulnerability +- **Location**: Backend `tokenRevocation.js` +- **Risk**: Token manipulation +- **Impact**: Bypass revocation checks +- **Remediation**: Verify token signature before decoding + +### 3. Source Maps in Production +- **Location**: Frontend build configuration +- **Risk**: Source code exposure +- **Impact**: Reveals application structure +- **Remediation**: Disable in production builds + +### 4. Missing Security Headers +- **Location**: nginx configuration +- **Missing**: HSTS, Permissions-Policy +- **Impact**: Various client-side attacks +- **Remediation**: Add comprehensive security headers + +--- + +## ๐ŸŸก MEDIUM SEVERITY FINDINGS + +### 1. Rate Limiting Bypass Potential +- **Location**: Backend rate limiter +- **Risk**: DoS attacks +- **Current**: JWT validation in rate limiter +- **Remediation**: Use IP-based limiting only + +### 2. Incomplete SQL Injection Protection +- **Location**: Complex dashboard queries +- **Risk**: Potential injection in edge cases +- **Current**: Mostly parameterized +- **Remediation**: Use query builder exclusively + +### 3. Session Management +- **Issue**: No gallery token invalidation on password change +- **Risk**: Persistent access after compromise +- **Remediation**: Implement token revocation + +### 4. Path Traversal in Gallery Slugs +- **Location**: Frontend gallery routes +- **Risk**: Directory traversal attempts +- **Remediation**: Validate and sanitize slugs + +--- + +## ๐ŸŸข LOW SEVERITY FINDINGS + +### 1. Verbose Error Messages +- **Location**: Multiple API endpoints +- **Risk**: Information disclosure +- **Remediation**: Generic client errors, detailed server logs + +### 2. Weak Gallery Passwords +- **Current**: zxcvbn score 2/4 allowed +- **Risk**: Brute force attacks +- **Remediation**: Increase to score 3/4 + +### 3. Missing File Size Validation +- **Location**: Frontend upload components +- **Risk**: DoS via large uploads +- **Remediation**: Add client-side size checks + +--- + +## โœ… SECURITY STRENGTHS + +### Authentication & Authorization +- JWT with proper expiration (24h/7d) +- Token type validation +- IP tracking and validation +- Password change detection +- Token revocation system +- Bcrypt with 12 rounds +- zxcvbn password strength checking + +### Input Validation & SQL Security +- express-validator on all endpoints +- Parameterized queries via Knex +- SQL injection protection utilities +- Path traversal prevention +- Comprehensive input sanitization + +### File Security +- Magic number verification +- MIME type validation +- Safe filename generation +- Directory traversal protection +- File extension whitelist + +### Rate Limiting & DoS Protection +- General: 100 req/15min +- Auth endpoints: 5 req/15min +- Account lockout after failed attempts +- Suspicious activity detection + +### Frontend Security +- React's built-in XSS protection +- DOMPurify for HTML content +- No eval() or innerHTML usage +- Proper error boundaries +- ReCAPTCHA integration + +--- + +## ๐Ÿ“Š DEPENDENCY ANALYSIS + +### Current Status +- **Backend**: 0 vulnerabilities (691 packages) +- **Frontend**: 0 vulnerabilities (434 packages) + +### Recommended Updates +1. **bcrypt** 5.1.1 โ†’ 6.0.0 (performance, compatibility) +2. **helmet** 7.2.0 โ†’ 8.1.0 (new security features) +3. **@tiptap** 2.x โ†’ 3.x (security improvements) + +### Supply Chain Assessment +- All major dependencies from trusted sources +- No typosquatting detected +- Regular maintenance observed +- MIT/ISC/Apache licenses only + +--- + +## ๐Ÿ› ๏ธ REMEDIATION PLAN + +### Phase 1: Critical (Within 24 hours) +1. Replace hardcoded JWT secret with secure random value +2. Move gallery tokens from localStorage to httpOnly cookies +3. Implement strict CSP without unsafe-eval +4. Remove or wrap console.log statements + +### Phase 2: High Priority (Within 1 week) +1. Disable source maps in production +2. Add missing security headers (HSTS, Permissions-Policy) +3. Fix token revocation vulnerability +4. Update critical dependencies (bcrypt, helmet) + +### Phase 3: Medium Priority (Within 1 month) +1. Implement comprehensive logging strategy +2. Add gallery slug validation +3. Enhance rate limiting logic +4. Implement session invalidation on password change + +### Phase 4: Ongoing +1. Weekly dependency scanning +2. Implement security testing in CI/CD +3. Regular penetration testing +4. Security awareness training + +--- + +## ๐Ÿ”’ RECOMMENDED CSP CONFIGURATION + +```nginx +add_header Content-Security-Policy " + default-src 'self'; + script-src 'self' 'nonce-{RANDOM}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: blob: https:; + font-src 'self'; + connect-src 'self' https://analytics.domain.com; + frame-src https://www.google.com/recaptcha/; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + upgrade-insecure-requests; +" always; +``` + +--- + +## ๐Ÿš€ SECURITY IMPROVEMENTS ROADMAP + +### Immediate Implementation +```bash +# 1. Generate secure secrets +openssl rand -base64 32 > jwt-secret.txt + +# 2. Update dependencies +cd backend && npm install bcrypt@^6.0.0 helmet@^8.1.0 +cd ../frontend && npm update + +# 3. Add security scanning +npm install -D npm-audit-resolver +``` + +### CI/CD Integration +```yaml +# Add to CI pipeline +- name: Security Scan + run: | + npm audit --audit-level=moderate + npm run test:security +``` + +### Monitoring & Alerting +1. Implement fail2ban for repeated auth failures +2. Set up log analysis for suspicious patterns +3. Configure alerts for security events +4. Regular vulnerability scanning + +--- + +## ๐Ÿ“‹ COMPLIANCE CHECKLIST + +- [ ] OWASP Top 10 addressed +- [ ] GDPR compliance (data minimization, right to erasure) +- [ ] Security headers implemented +- [ ] Dependency scanning automated +- [ ] Incident response plan documented +- [ ] Security documentation maintained +- [ ] Regular security reviews scheduled + +--- + +## ๐ŸŽฏ CONCLUSION + +The wedding photo sharing application has a solid security foundation with excellent input validation and authentication mechanisms. However, operational security practices need immediate attention. The critical issues around secret management and token storage must be addressed before production deployment. + +Implementing the recommended fixes will raise the security score from 6.5/10 to approximately 8.5/10, providing a robust and secure platform for wedding photo sharing. + +--- + +*Generated by Claude Security Scanner v1.0* +*Next scan recommended: After Phase 1 remediation completion* \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f7be11 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore +*.md +.env +.env.* +docker-compose*.yml +.DS_Store +node_modules +npm-debug.log +coverage +.nyc_output +.vscode +.idea +*.swp +*.swo +storage/events/active/* +storage/events/archived/* +storage/thumbnails/* +data/*.db +logs/* diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..df83cb0 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,77 @@ +kind: pipeline +type: docker +name: default + +steps: + # Build Backend Docker Image + - name: build-backend + image: plugins/docker + settings: + repo: registry.local.nothaft.cloud/picpeak-backend + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + - ${DRONE_BRANCH}-latest + dockerfile: backend/Dockerfile + context: backend/ + registry: registry.local.nothaft.cloud + build_args: + - VERSION=${DRONE_TAG:-dev} + + # Build Frontend Docker Image + - name: build-frontend + image: plugins/docker + settings: + repo: registry.local.nothaft.cloud/picpeak-frontend + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + - ${DRONE_BRANCH}-latest + dockerfile: frontend/Dockerfile + context: frontend/ + registry: registry.local.nothaft.cloud + build_args: + - VERSION=${DRONE_TAG:-dev} + - VITE_API_URL=${VITE_API_URL:-/api} + +trigger: + branch: + - main + - develop + event: + - push + - pull_request + +--- +kind: pipeline +type: docker +name: release + +steps: + # Build Backend Release + - name: build-backend-release + image: plugins/docker + settings: + repo: registry.local.nothaft.cloud/picpeak-backend + tags: + - ${DRONE_TAG} + - latest + dockerfile: backend/Dockerfile + context: backend/ + registry: registry.local.nothaft.cloud + + # Build Frontend Release + - name: build-frontend-release + image: plugins/docker + settings: + repo: registry.local.nothaft.cloud/picpeak-frontend + tags: + - ${DRONE_TAG} + - latest + dockerfile: frontend/Dockerfile + context: frontend/ + registry: registry.local.nothaft.cloud + +trigger: + event: + - tag \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b008cf9 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# Environment Configuration Template +# Copy this file to .env and adjust values for your environment + +# Development: Use docker-compose.dev.yml +# Production: Use docker-compose.prod.yml with .env.production.example + +# JWT Secret (CRITICAL for production) +# Generate with: openssl rand -base64 32 +JWT_SECRET=dev-secret-change-in-production + +# Application URLs +ADMIN_URL=http://localhost:3005 +FRONTEND_URL=http://localhost:3005 + +# Database Configuration +# SQLite is used for development by default +# For production PostgreSQL config, see .env.production.example +DATABASE_CLIENT=sqlite3 +DATABASE_PATH=./data/photo_sharing.db + +# Email Configuration +# Development: Uses Mailhog (included in docker-compose.dev.yml) +# Production: Configure real SMTP server +SMTP_HOST=mailhog +SMTP_PORT=1025 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASS= +EMAIL_FROM=noreply@localhost + +# Optional: Umami Analytics +UMAMI_URL= +UMAMI_WEBSITE_ID= +UMAMI_HASH_SALT= \ No newline at end of file diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..3812f0a --- /dev/null +++ b/.env.production.example @@ -0,0 +1,44 @@ +# PicPeak Production Configuration +# Copy this file to .env and update with your values + +# Required: Security +JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING + +# Required: URLs (update with your domain) +FRONTEND_URL=https://your-domain.com +BACKEND_URL=https://your-domain.com +ADMIN_URL=https://your-domain.com + +# Required: Email Settings +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +SMTP_FROM=your-email@gmail.com + +# Required: Initial Admin Account +ADMIN_EMAIL=admin@your-domain.com +ADMIN_PASSWORD=change-this-password + +# Database (PostgreSQL recommended for production) +DATABASE_CLIENT=pg +DB_HOST=postgres +DB_PORT=5432 +DB_NAME=picpeak +DB_USER=picpeak +DB_PASSWORD=secure-database-password + +# Optional: Customization +SITE_NAME=PicPeak +DEFAULT_EXPIRATION_DAYS=30 +SESSION_TIMEOUT_MINUTES=60 + +# Optional: Analytics (Umami) +VITE_UMAMI_URL= +VITE_UMAMI_WEBSITE_ID= + +# Advanced: Performance Tuning +NODE_ENV=production +BCRYPT_ROUNDS=12 +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX_REQUESTS=100 \ No newline at end of file diff --git a/.gitattributes-github b/.gitattributes-github new file mode 100644 index 0000000..9da591d --- /dev/null +++ b/.gitattributes-github @@ -0,0 +1,12 @@ +# Files to exclude from GitHub mirror +.env* export-ignore +docker-compose.prod.yml export-ignore +.claudedocs/ export-ignore +backend/data/ export-ignore +backend/storage/ export-ignore +backend/.env* export-ignore +frontend/.env* export-ignore +secrets/ export-ignore +*.key export-ignore +*.pem export-ignore +.gitea/ export-ignore \ No newline at end of file diff --git a/.gitea/workflows/mirror-to-github.yml b/.gitea/workflows/mirror-to-github.yml new file mode 100644 index 0000000..86f1928 --- /dev/null +++ b/.gitea/workflows/mirror-to-github.yml @@ -0,0 +1,99 @@ +name: Mirror to GitHub + +on: + push: + branches: + - main + workflow_dispatch: # Allow manual triggering + +jobs: + mirror: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 # Full history needed for mirroring + + - name: Setup Git + run: | + git config --global user.name "the-luap" + git config --global user.email "paul-nothaft@hotmail.de" + + - name: Debug - Show current branch and status + run: | + echo "Current branch:" + git branch -a + echo "Git status:" + git status + echo "Remote info:" + git remote -v + + - name: Create filtered branch + run: | + # Clean up any existing github-mirror branch + git branch -D github-mirror || true + + # Create a new branch for GitHub + git checkout --orphan github-mirror + + # Remove sensitive files/directories + # Example: Remove .env files, private configs, etc. + git rm -r --cached .env* || true + git rm -r --cached backend/.env* || true + git rm -r --cached frontend/.env* || true + git rm -r --cached docker-compose.prod.yml || true + git rm -r --cached .claudedocs/ || true + git rm -r --cached backend/data/ || true + git rm -r --cached backend/storage/ || true + git rm -r --cached .gitea/ || true + git rm -r --cached scripts/install-gitea-runner.sh || true + git rm -r --cached .drone* || true + git rm -r --cached .github-mirror-exclude || true + git rm -r --cached .gitattributes-github || true + git rm -r --cached photo-sharing-prd.md || true + git rm -r --cached CLAUDE.md || true + git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true + git rm -r --cached logs/ || true + git rm -r --cached frontend/.claudedocs/ || true + git rm -r --cached test-maintenance.sh || true + git rm -r --cached storage/ || true + + + # Commit the changes + git commit -m "Remove sensitive files for GitHub mirror" || true + + - name: Check GitHub token + env: + GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }} + run: | + if [ -z "$GITHUBTOKEN" ]; then + echo "ERROR: GITHUBTOKEN secret is not set!" + exit 1 + else + echo "GitHub token is available (length: ${#GITHUBTOKEN})" + fi + + - name: Push to GitHub + env: + GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }} + run: | + # Remove existing github remote if it exists + git remote remove github || true + + # Add GitHub remote + git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git + + # Verify remote was added + echo "GitHub remote added:" + git remote -v + + # Force push the filtered branch to GitHub main + echo "Pushing to GitHub..." + git push github github-mirror:main --force + echo "Push completed successfully!" + + - name: Workflow completed + run: | + echo "โœ… Mirror to GitHub workflow completed successfully!" + echo "Check https://github.com/the-luap/picpeak to verify the mirror." \ No newline at end of file diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..c207bdc --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,52 @@ +name: Test and Lint + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + backend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install backend dependencies + working-directory: ./backend + run: npm ci + + - name: Run backend linting + working-directory: ./backend + run: npm run lint || true # Continue on lint errors for now + + - name: Run backend tests + working-directory: ./backend + run: npm test || true # Continue on test failures for now + + frontend-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install frontend dependencies + working-directory: ./frontend + run: npm ci --legacy-peer-deps + + - name: Run frontend linting + working-directory: ./frontend + run: npm run lint || true # Continue on lint errors for now + + - name: Build frontend + working-directory: ./frontend + run: npm run build \ No newline at end of file diff --git a/.gitea/workflows/version-and-release.yml b/.gitea/workflows/version-and-release.yml new file mode 100644 index 0000000..ebd5f8a --- /dev/null +++ b/.gitea/workflows/version-and-release.yml @@ -0,0 +1,209 @@ +name: Version and Release + +on: + push: + branches: [ main ] + paths-ignore: + - '**.md' + - '.gitea/**' + - '.drone.yml' + +jobs: + version-bump: + runs-on: ubuntu-latest + outputs: + new_version: ${{ steps.version.outputs.new_version }} + version_changed: ${{ steps.version.outputs.version_changed }} + component_changed: ${{ steps.version.outputs.component_changed }} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN || github.token }} + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Configure Git + run: | + git config --global user.name 'Gitea Actions Bot' + git config --global user.email 'actions@gitea.local' + + - name: Detect changes and bump version + id: version + run: | + set -e # Exit on error + + echo "=== Debug Info ===" + echo "GitHub event before: ${{ github.event.before }}" + echo "GitHub SHA: ${{ github.sha }}" + echo "Current directory: $(pwd)" + echo "Git log (last 5): $(git log --oneline -5)" + + # Get the commit range for changed files + if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then + COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}" + echo "Using commit range: $COMMIT_RANGE" + CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "") + else + # First commit or no previous commit, check against HEAD~1 if it exists + if git rev-parse HEAD~1 >/dev/null 2>&1; then + COMMIT_RANGE="HEAD~1..HEAD" + echo "Using commit range: $COMMIT_RANGE" + CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "") + else + echo "First commit detected, checking all files" + CHANGED_FILES=$(git ls-files) + fi + fi + + echo "Changed files:" + echo "$CHANGED_FILES" + + # Check what changed (using echo to pipe to grep to avoid grep exit codes) + BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0") + FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0") + ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0") + + echo "Backend files changed: $BACKEND_CHANGED" + echo "Frontend files changed: $FRONTEND_CHANGED" + echo "Root files changed: $ROOT_CHANGED" + + # Get current versions + BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0") + FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0") + + echo "Current backend version: $BACKEND_VERSION" + echo "Current frontend version: $FRONTEND_VERSION" + + # Determine what to update based on changes + BACKEND_UPDATE=false + FRONTEND_UPDATE=false + COMPONENT_CHANGED="none" + + if [ "$ROOT_CHANGED" -gt 0 ]; then + # Root changes affect both components + BACKEND_UPDATE=true + FRONTEND_UPDATE=true + COMPONENT_CHANGED="both" + SOURCE_VERSION=$BACKEND_VERSION + echo "Root changes detected - updating both components" + elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then + # Both components changed + BACKEND_UPDATE=true + FRONTEND_UPDATE=true + COMPONENT_CHANGED="both" + # Use the higher version as source + if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then + SOURCE_VERSION=$BACKEND_VERSION + else + SOURCE_VERSION=$FRONTEND_VERSION + fi + echo "Both backend and frontend changed - updating both" + elif [ "$BACKEND_CHANGED" -gt 0 ]; then + # Only backend changed + BACKEND_UPDATE=true + COMPONENT_CHANGED="backend" + SOURCE_VERSION=$BACKEND_VERSION + echo "Only backend changed - updating backend" + elif [ "$FRONTEND_CHANGED" -gt 0 ]; then + # Only frontend changed + FRONTEND_UPDATE=true + COMPONENT_CHANGED="frontend" + SOURCE_VERSION=$FRONTEND_VERSION + echo "Only frontend changed - updating frontend" + else + echo "No relevant changes detected" + echo "version_changed=false" >> $GITHUB_OUTPUT + echo "component_changed=none" >> $GITHUB_OUTPUT + echo "new_version=" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Component changed: $COMPONENT_CHANGED" + echo "Source version: $SOURCE_VERSION" + echo "Backend update: $BACKEND_UPDATE" + echo "Frontend update: $FRONTEND_UPDATE" + + # Calculate new version + IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION" + MAJOR="${version_parts[0]}" + MINOR="${version_parts[1]}" + PATCH="${version_parts[2]}" + + # Increment patch version + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT + + # Update versions in package.json files + if [ "$BACKEND_UPDATE" = true ]; then + echo "Updating backend version to $NEW_VERSION" + cd backend && npm version $NEW_VERSION --no-git-tag-version + cd .. + fi + + if [ "$FRONTEND_UPDATE" = true ]; then + echo "Updating frontend version to $NEW_VERSION" + cd frontend && npm version $NEW_VERSION --no-git-tag-version + cd .. + fi + + # Check if there are changes to commit + if [[ -n $(git status --porcelain) ]]; then + echo "version_changed=true" >> $GITHUB_OUTPUT + else + echo "version_changed=false" >> $GITHUB_OUTPUT + fi + + - name: Commit version bump + if: steps.version.outputs.version_changed == 'true' + run: | + COMPONENT="${{ steps.version.outputs.component_changed }}" + + if [ "$COMPONENT" = "both" ]; then + git add backend/package.json backend/package-lock.json + git add frontend/package.json frontend/package-lock.json + git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)" + elif [ "$COMPONENT" = "backend" ]; then + git add backend/package.json backend/package-lock.json + git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}" + elif [ "$COMPONENT" = "frontend" ]; then + git add frontend/package.json frontend/package-lock.json + git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}" + fi + + git push + + - name: Create Git tag + if: steps.version.outputs.version_changed == 'true' + run: | + COMPONENT="${{ steps.version.outputs.component_changed }}" + + if [ "$COMPONENT" = "both" ]; then + TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)" + elif [ "$COMPONENT" = "backend" ]; then + TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)" + elif [ "$COMPONENT" = "frontend" ]; then + TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)" + fi + + git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE" + git push origin "v${{ steps.version.outputs.new_version }}" + + trigger-drone: + needs: version-bump + if: needs.version-bump.outputs.version_changed == 'true' + runs-on: ubuntu-latest + steps: + - name: Trigger Drone Build + run: | + echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}" + echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}" + echo "Drone will automatically trigger on the new tag" + # Drone CI will automatically trigger on the tag push event \ No newline at end of file diff --git a/.github-mirror-exclude b/.github-mirror-exclude new file mode 100644 index 0000000..dbc08bc --- /dev/null +++ b/.github-mirror-exclude @@ -0,0 +1,24 @@ +# Exclude patterns for GitHub mirror +.env +.env.* +.env* +docker-compose.prod.yml +docker-compose.traefik.yml +.claudedocs/ +backend/data/ +backend/storage/ +backend/.env* +frontend/.env* +secrets/ +*.key +*.pem +.gitea/ +node_modules/ +dist/ +build/ +*.log +.DS_Store +deploy/ +certbot/ +nginx/ +photo-sharing-prd.md \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..6e0f33f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,47 @@ +--- +name: Bug report +about: Create a report to help us improve PicPeak +title: '[BUG] ' +labels: 'bug' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Environment (please complete the following information):** + - OS: [e.g. Ubuntu 22.04] + - Browser: [e.g. Chrome 120, Safari 17] + - PicPeak Version: [e.g. 1.0.22] + - Deployment Method: [e.g. Docker Compose, Manual] + - Database: [e.g. PostgreSQL 15, SQLite] + +**Logs** +Please include relevant logs: +``` +# Backend logs +docker-compose logs backend | tail -50 + +# Frontend console errors +[paste any browser console errors] +``` + +**Additional context** +Add any other context about the problem here. + +**Possible Solution** +If you have an idea how to fix the issue, please describe it here. \ 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..ed0a0f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: ๐Ÿ“š Documentation + url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md + about: Please read the documentation before opening an issue + - name: ๐Ÿ’ฌ Discussions + url: https://github.com/the-luap/picpeak/discussions + about: Ask questions and discuss with the community + - name: ๐Ÿ”’ Security Issues + url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md + about: Please review our security policy for reporting vulnerabilities \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md new file mode 100644 index 0000000..5f39614 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.md @@ -0,0 +1,33 @@ +--- +name: Documentation +about: Report issues or improvements needed in documentation +title: '[DOCS] ' +labels: 'documentation' +assignees: '' + +--- + +**What documentation needs improvement?** +Please specify which document or section needs attention: +- [ ] README.md +- [ ] DEPLOYMENT.md +- [ ] CONTRIBUTING.md +- [ ] API Documentation +- [ ] Code Comments +- [ ] Other: ___________ + +**Describe the issue** +What's wrong or missing in the documentation? + +**Suggested improvement** +How would you improve this documentation? + +**Target audience** +Who is this documentation for? +- [ ] New users setting up PicPeak +- [ ] Developers contributing to the project +- [ ] System administrators +- [ ] End users (photographers/clients) + +**Additional context** +Add any other context, examples, or references here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8afa105 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,38 @@ +--- +name: Feature request +about: Suggest an idea for PicPeak +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Use Case** +Please describe how this feature would be used: +- Who would use it? (photographers, clients, admins) +- When would they use it? +- Why is it important? + +**Similar Features** +Are there similar features in: +- PicDrop +- Scrapbook.de +- Other photo sharing platforms + +**Mockups or Examples** +If applicable, add mockups, diagrams, or links to similar implementations. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**Implementation Ideas** +If you have technical ideas about how this could be implemented, please share them. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..9d20dc5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,26 @@ +--- +name: Question +about: Ask a question about PicPeak +title: '[QUESTION] ' +labels: 'question' +assignees: '' + +--- + +**Question** +What would you like to know about PicPeak? + +**Context** +Please provide context to help us answer your question better: +- What are you trying to achieve? +- What have you already tried? +- Which documentation have you consulted? + +**Environment** +If relevant to your question: +- PicPeak Version: +- Deployment Method: +- Operating System: + +**Related Issues or Discussions** +Link to any related issues, discussions, or documentation. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/security_vulnerability.md b/.github/ISSUE_TEMPLATE/security_vulnerability.md new file mode 100644 index 0000000..6846408 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/security_vulnerability.md @@ -0,0 +1,37 @@ +--- +name: Security Vulnerability +about: Report security issues privately +title: '[SECURITY] ' +labels: 'security' +assignees: '' + +--- + +โš ๏ธ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.** + +Instead, please email security@example.com with the details. + +For minor security improvements or questions, you can use this template: + +**Type of Security Issue** +- [ ] Authentication/Authorization +- [ ] Data Exposure +- [ ] Input Validation +- [ ] Configuration Issue +- [ ] Dependency Vulnerability +- [ ] Other: ___________ + +**Description** +Brief description of the security concern. + +**Impact** +What could an attacker potentially do? + +**Steps to Reproduce** +If applicable, how can this be reproduced? + +**Suggested Fix** +If you have ideas on how to fix this issue. + +**References** +Any relevant security advisories, CVEs, or documentation. \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..ae794d4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,49 @@ +## Description + +Please include a summary of the changes and which issue is fixed. Include relevant motivation and context. + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring + +## How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. + +- [ ] Unit tests pass (`npm test`) +- [ ] Manual testing completed +- [ ] Tested on Docker deployment +- [ ] Tested on production-like environment + +**Test Configuration**: +* PicPeak Version: +* Node.js Version: +* Database: PostgreSQL / SQLite +* Browser: + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published +- [ ] I have updated the CHANGELOG.md file + +## Screenshots (if appropriate): + +## Additional Notes: + +Add any additional notes, concerns, or discussion points here. \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3fa5ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Security - Never commit credentials +ADMIN_CREDENTIALS.txt +ADMIN_PASSWORD_RESET.txt +*_CREDENTIALS.txt +*_PASSWORD_RESET.txt + +# Storage and data +storage/events/active/* +storage/events/archived/* +storage/thumbnails/* +data/*.db +data/*.db-journal +logs/* + +# Build outputs +build/ +dist/ +*.log + +# OS files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# Test coverage +coverage/ +.nyc_output/ + +# Temporary files +*.tmp +*.temp + +# Keep directory structure +!storage/events/active/.gitkeep +!storage/events/archived/.gitkeep +!storage/thumbnails/.gitkeep +!data/.gitkeep +!logs/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f878c85 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,261 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Product Overview + +A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI. + +## Architecture Overview + +- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage +- **Frontend**: React SPA with scrappbook.de-style design (requires implementation) +- **Storage**: File-based with active/archived separation +- **Services**: Background workers for email, archiving, file watching, and expiration monitoring +- **Analytics**: Umami integration for engagement tracking + +## Essential Commands + +### Backend Development +```bash +cd backend +npm install # Install dependencies +npm run migrate # Initialize database schema +npm run dev # Start with hot-reload (port 3001) +npm test # Run Jest tests +npm run lint # ESLint checks +``` + +### Running a Single Test +```bash +cd backend +npm test -- path/to/test.test.js +npm test -- --testNamePattern="test name" +``` + +### Production +```bash +docker-compose -f docker-compose.prod.yml up -d # Production deployment +pm2 start ecosystem.config.js # Alternative: PM2 deployment +``` + +## Key Product Requirements (from PRD) + +### Core Features +1. **File-Based System**: Drop photos in folders โ†’ automatic gallery creation +2. **Automatic Expiration**: Default 30 days, with 7-day warning emails +3. **Password Protection**: Secure access with customizable passwords +4. **Automatic Archiving**: ZIP compression and storage after expiration +5. **Email Notifications**: Creation, warning, and expiration notifications +6. **Analytics**: Umami tracking for views, downloads, and engagement + +### Folder Structure +``` +/events/ + โ”œโ”€โ”€ active/ + โ”‚ โ”œโ”€โ”€ wedding-smith-jones-2024-06-15/ + โ”‚ โ”‚ โ”œโ”€โ”€ collages/ + โ”‚ โ”‚ โ””โ”€โ”€ individual/ + โ”‚ โ””โ”€โ”€ birthday-emma-2024-07-20/ + โ””โ”€โ”€ archived/ + โ””โ”€โ”€ wedding-smith-jones-2024-06-15.zip +``` + +## Frontend Implementation Requirements + +### Design Style (scrappbook.de-inspired) +- **Color Palette**: Primary green (#5C8762), neutral backgrounds +- **Typography**: Clean, modern sans-serif (Noto Sans or similar) +- **Layout**: Minimalist, modular sections with grid-based photo displays +- **Aesthetic**: Professional yet approachable, photographer-focused + +### Key Frontend Components to Build +1. **Landing Page**: Password entry with event preview +2. **Gallery View**: + - Responsive photo grid with lazy loading + - Toggle between collages/individual photos + - Prominent expiration banner + - Download urgency indicators +3. **Photo Lightbox**: Full-screen viewing with zoom +4. **Mobile-First**: Responsive design with touch gestures +5. **Personalization**: Dynamic theming per event type + +### User Experience Priorities +- Clear expiration warnings (sticky banner) +- One-click "Download All" for urgent galleries +- Smooth image loading with skeleton screens +- Intuitive navigation between photo categories +- Professional presentation matching photographer branding + +## Key Architecture Patterns + +### Authentication Flow +- JWT-based with separate tokens for admin and gallery access +- Gallery tokens include event-specific claims +- Auth middleware: `backend/src/middleware/auth.js` + - `adminAuth` - Admin panel protection + - `photoAuth` - Protected photo access + - `verifyGalleryAccess` - Gallery-specific validation + +### Database Schema (Knex/SQLite) +Main tables: +- `events` - Gallery metadata with expiration, custom messages, themes +- `photos` - Photo records linked to events +- `access_logs` - IP-based usage tracking +- `email_queue` - Async email processing +- `admin_users` - Admin authentication + +### Service Architecture +Background services run as separate processes: +- **emailService**: Processes email queue with retry logic +- **archiveService**: Creates ZIP archives of expired events +- **expirationChecker**: Cron job for expiration warnings +- **fileWatcher**: Monitors for new photo uploads + +### API Structure +- `/api/admin/*` - Admin panel endpoints (requires adminAuth) +- `/api/gallery/*` - Public gallery endpoints +- `/api/auth/*` - Authentication endpoints +- Rate limiting: 100 req/15min (general), 5 req/15min (auth) + +## Critical Implementation Notes + +1. **Security**: All gallery access requires valid JWT with event-specific claims +2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings +3. **Email Queue**: Async processing with retry logic, check `email_queue` table +4. **File Processing**: Sharp library for thumbnail generation (300x300) +5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD +6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings + +## Environment Variables + +### Backend (.env) +- `JWT_SECRET` - Token signing +- `ADMIN_URL`, `FRONTEND_URL` - CORS origins +- `SMTP_*` - Email configuration +- `DB_*` - PostgreSQL credentials (production) +- `UMAMI_URL` - Umami instance URL (for server-side tracking) +- `UMAMI_WEBSITE_ID` - Website ID from Umami + +### Frontend (.env) +- `VITE_API_URL` - Backend API URL +- `VITE_UMAMI_URL` - Umami analytics URL +- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami +- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard + +## Testing Approach +- Jest with Supertest for API testing +- Test files in `__tests__` directories +- Database migrations run before tests +- Mock email sending in tests + +## Umami Analytics Integration + +The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance. + +### Tracked Events: +- **Gallery Events**: + - `gallery_password_entry` - Password attempts (success/failure) + - `gallery_photo_view` - Individual photo views + - `gallery_photo_download` - Single photo downloads + - `gallery_bulk_download` - Bulk/all photo downloads + - `gallery_expired` - Expired gallery access attempts +- **Admin Events**: + - `admin_login` - Admin authentication + - `admin_event_created` - New event creation + - `admin_event_archived` - Event archiving + - `admin_event_deleted` - Event deletion + - `admin_settings_updated` - Settings changes +- **User Behavior**: + - Search queries (with debouncing) + - Expiration warning views + - Page views with automatic tracking + +### Setup: +1. Install Umami (self-hosted or cloud) +2. Create a website in Umami dashboard +3. Set environment variables: + ``` + VITE_UMAMI_URL=https://your-umami-instance.com + VITE_UMAMI_WEBSITE_ID=your-website-id + VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/... + ``` + +### Analytics Dashboard: +- Admin panel includes analytics page at `/admin/analytics` +- Summary view with key metrics +- Option to embed full Umami dashboard +- Real-time event tracking + +## Accessibility & Performance Features + +### Accessibility (WCAG 2.1 AA Compliance) +- **Error Boundaries**: Graceful error handling with recovery options +- **Skip Links**: Skip to main content for keyboard navigation +- **ARIA Labels**: Proper labeling for screen readers +- **Focus Management**: Focus trap in modals, visible focus indicators +- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download) +- **Loading States**: Skeleton screens instead of spinners for better UX +- **Offline Support**: Visual indicator when offline +- **Form Validation**: Accessible error messages with aria-describedby + +### Performance Optimizations +- **Lazy Loading**: Images load on scroll with Intersection Observer +- **Skeleton Screens**: Instant visual feedback during loading +- **Error Recovery**: Component-level error boundaries prevent full page crashes +- **Optimistic Updates**: Immediate UI updates with background sync +- **Debounced Search**: Prevents excessive API calls +- **Analytics**: Non-blocking Umami integration + +### Component Library Enhancements +- `` - Catches and displays errors gracefully +- `` - Full-page error recovery +- `` - Flexible skeleton loader with variants +- `` - Network status monitoring +- `` - Accessibility navigation +- `useFocusTrap` - Modal focus management hook +- `useOnlineStatus` - Network status hook + +## Theme System & Branding + +### Theme Features +- **Dynamic Theming**: CSS variables for runtime theme switching +- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal +- **Customization Options**: + - Primary/Accent/Background/Text colors + - Font family selection + - Border radius (none, sm, md, lg) + - Custom logo upload + - Custom CSS injection +- **Event-Specific Themes**: Override global theme per gallery +- **Live Preview**: Real-time theme changes in admin panel + +### Theme Context API +```typescript +const { theme, setTheme, setThemeByName } = useTheme(); +``` + +### Branding Settings +- Company name, tagline, and support email +- Custom footer text +- Optional watermarking on downloads +- Logo upload for gallery header + +### CSS Variables +```css +--color-primary: #5C8762; +--color-primary-light: #7aa583; +--color-primary-dark: #4a6f4f; +--color-accent: #22c55e; +--color-background: #fafafa; +--color-text: #171717; +--font-family: 'Inter', sans-serif; +--border-radius: 0.5rem; +``` + +## Success Metrics (from PRD) +- Time to generate gallery: <2 minutes +- Guest satisfaction: >90% +- System uptime: 99.9% +- Email delivery rate: >98% +- Successful archiving: 100% \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..5aa561c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,27 @@ +# PicPeak Community Guidelines + +## Our Commitment + +We are committed to providing a welcoming and inspiring community for all photographers and developers. + +## Expected Behavior + +* Be respectful and considerate +* Welcome newcomers and help them get started +* Focus on what is best for the community +* Show empathy towards other community members + +## Unacceptable Behavior + +* Trolling or insulting comments +* Personal attacks +* Public or private harassment +* Publishing others' private information + +## Enforcement + +Instances of unacceptable behavior may be reported to the project team at conduct@example.com. All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from contributor-covenant.org, version 2.0. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5d3e01e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,160 @@ +# Contributing to PicPeak + +First off, thank you for considering contributing to PicPeak! It's people like you that make PicPeak such a great tool for photographers worldwide. + +## ๐Ÿค Code of Conduct + +This project and everyone participating in it is governed by the [PicPeak Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. + +## ๐ŸŽฏ How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible: + +* **Use a clear and descriptive title** +* **Describe the exact steps to reproduce the problem** +* **Provide specific examples to demonstrate the steps** +* **Describe the behavior you observed and what you expected** +* **Include screenshots if possible** +* **Include your environment details** (OS, browser, Docker version, etc.) + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include: + +* **Use a clear and descriptive title** +* **Provide a detailed description of the suggested enhancement** +* **Provide specific examples to demonstrate the enhancement** +* **Describe the current behavior and expected behavior** +* **Explain why this enhancement would be useful** + +### Your First Code Contribution + +Unsure where to begin? You can start by looking through these issues: + +* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code +* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention + +### Pull Requests + +1. **Fork the repo** and create your branch from `main` +2. **Install dependencies**: + ```bash + cd backend && npm install + cd ../frontend && npm install + ``` +3. **Make your changes** and ensure: + - Code follows the existing style + - Tests pass: `npm test` + - Linting passes: `npm run lint` +4. **Write tests** if you've added code +5. **Update documentation** if needed +6. **Create a Pull Request** + +## ๐Ÿ’ป Development Setup + +### Prerequisites + +- Node.js 18+ +- Docker & Docker Compose +- Git + +### Local Development + +```bash +# Clone your fork +git clone https://github.com/your-username/picpeak.git +cd picpeak + +# Install dependencies +cd backend && npm install +cd ../frontend && npm install + +# Set up environment +cp .env.example .env +# Edit .env with your settings + +# Start development servers +docker-compose -f docker-compose.dev.yml up +``` + +### Running Tests + +```bash +# Backend tests +cd backend && npm test + +# Frontend tests +cd frontend && npm test + +# E2E tests +npm run test:e2e +``` + +## ๐Ÿ“ Styleguides + +### Git Commit Messages + +* Use the present tense ("Add feature" not "Added feature") +* Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +* Limit the first line to 72 characters or less +* Reference issues and pull requests liberally after the first line +* Consider starting the commit message with an applicable emoji: + * ๐ŸŽจ `:art:` when improving the format/structure of the code + * ๐Ÿ› `:bug:` when fixing a bug + * ๐Ÿ”ฅ `:fire:` when removing code or files + * ๐Ÿ“ `:memo:` when writing docs + * ๐Ÿš€ `:rocket:` when improving performance + * โœจ `:sparkles:` when adding a new feature + +### JavaScript/TypeScript Styleguide + +* Use ES6+ features +* Prefer async/await over promises +* Use meaningful variable names +* Add JSDoc comments for functions +* Follow ESLint rules + +### React Styleguide + +* Use functional components with hooks +* Keep components small and focused +* Use TypeScript for type safety +* Follow the existing folder structure +* Write tests for new components + +## ๐Ÿ“ฆ Project Structure + +``` +picpeak/ +โ”œโ”€โ”€ backend/ +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ routes/ # API endpoints +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # Business logic +โ”‚ โ”‚ โ”œโ”€โ”€ middleware/ # Express middleware +โ”‚ โ”‚ โ””โ”€โ”€ utils/ # Utilities +โ”‚ โ””โ”€โ”€ migrations/ # Database migrations +โ”œโ”€โ”€ frontend/ +โ”‚ โ”œโ”€โ”€ src/ +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # Reusable components +โ”‚ โ”‚ โ”œโ”€โ”€ pages/ # Page components +โ”‚ โ”‚ โ”œโ”€โ”€ services/ # API services +โ”‚ โ”‚ โ””โ”€โ”€ hooks/ # Custom hooks +โ”‚ โ””โ”€โ”€ public/ # Static assets +``` + +## ๐Ÿ”„ Release Process + +1. Update version numbers in package.json files +2. Update CHANGELOG.md +3. Create a new release on GitHub +4. Docker images are automatically built and published + +## ๐Ÿ“ฎ Contact + +- Create an issue for bugs or features +- Join discussions for questions +- Email: picpeak@example.com for security issues + +Thank you for contributing! ๐ŸŽ‰ \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..61ace45 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,220 @@ +# ๐Ÿš€ PicPeak Deployment Guide + +This guide will help you deploy PicPeak in production. The entire process takes about 10-15 minutes. + +## ๐Ÿ“‹ Prerequisites + +- A server with Docker and Docker Compose installed +- A domain name (for SSL certificates) +- SMTP credentials for sending emails +- Basic command line knowledge + +## ๐Ÿƒ Quick Deploy (Recommended) + +### 1. Clone and Configure + +```bash +# Clone the repository +git clone https://github.com/the-luap/picpeak.git +cd picpeak + +# Copy environment template +cp .env.production.example .env + +# Generate a secure JWT secret +echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env + +# Edit configuration +nano .env +``` + +### 2. Required Environment Variables + +Edit your `.env` file with these essential settings: + +```env +# Application URLs +FRONTEND_URL=https://your-domain.com +BACKEND_URL=https://your-domain.com + +# Email Configuration (Required for notifications) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +SMTP_FROM=your-email@gmail.com + +# Admin Configuration +ADMIN_EMAIL=admin@your-domain.com +ADMIN_PASSWORD=your-secure-password + +# Database (PostgreSQL for production) +DATABASE_CLIENT=pg +DB_HOST=postgres +DB_NAME=picpeak +DB_USER=picpeak +DB_PASSWORD=secure-db-password +``` + +### 3. Deploy with Docker Compose + +```bash +# Start all services +docker-compose -f docker-compose.prod.yml up -d + +# Check logs +docker-compose logs -f + +# Access your site at https://your-domain.com +``` + +## ๐Ÿ”ง Configuration Options + +### Storage Settings + +```env +# Storage paths (default: ./storage) +STORAGE_PATH=./storage +ARCHIVE_PATH=./storage/archives + +# Gallery expiration (days) +DEFAULT_EXPIRATION_DAYS=30 +WARNING_DAYS_BEFORE_EXPIRY=7 +``` + +### Security Settings + +```env +# Session timeout (minutes) +SESSION_TIMEOUT=60 + +# Rate limiting +RATE_LIMIT_WINDOW_MS=900000 # 15 minutes +RATE_LIMIT_MAX_REQUESTS=100 +``` + +### Analytics (Optional) + +```env +# Umami Analytics +VITE_UMAMI_URL=https://analytics.your-domain.com +VITE_UMAMI_WEBSITE_ID=your-website-id +``` + +## ๐Ÿ”’ SSL/TLS Setup + +The production Docker Compose includes automatic SSL via Let's Encrypt: + +1. **Ensure your domain points to your server** +2. **Update nginx configuration**: + ```bash + nano nginx/nginx.conf + # Replace your-domain.com with your actual domain + ``` +3. **Start services** - Certbot will automatically obtain certificates + +## ๐Ÿ“ Directory Structure + +After deployment, your directory structure will be: + +``` +picpeak/ +โ”œโ”€โ”€ backend/ # API server +โ”œโ”€โ”€ frontend/ # React app +โ”œโ”€โ”€ storage/ # Photo storage +โ”‚ โ”œโ”€โ”€ events/ # Active galleries +โ”‚ โ”‚ โ”œโ”€โ”€ active/ # Current photos +โ”‚ โ”‚ โ””โ”€โ”€ archived/ # Expired galleries +โ”‚ โ”œโ”€โ”€ thumbnails/ # Generated thumbnails +โ”‚ โ””โ”€โ”€ uploads/ # User uploads +โ”œโ”€โ”€ data/ # Database files +โ””โ”€โ”€ logs/ # Application logs +``` + +## ๐Ÿ”„ Maintenance + +### Backup + +```bash +# Backup database and photos +./scripts/backup.sh + +# Backups are stored in ./backups/ +``` + +### Update + +```bash +# Pull latest changes +git pull + +# Rebuild and restart +docker-compose -f docker-compose.prod.yml up -d --build +``` + +### Logs + +```bash +# View all logs +docker-compose logs + +# View specific service +docker-compose logs backend +docker-compose logs frontend +``` + +## ๐Ÿšจ Troubleshooting + +### Common Issues + +**Photos not appearing:** +- Check storage permissions: `chmod -R 755 storage/` +- Verify file watcher is running: `docker-compose logs backend | grep watcher` + +**Email not sending:** +- Test SMTP settings: Admin Panel โ†’ Settings โ†’ Email โ†’ Send Test +- Check email queue: Admin Panel โ†’ System โ†’ Email Queue + +**Can't access admin panel:** +- Default login: Use email/password from `.env` +- Reset password: `docker exec picpeak-backend npm run reset-admin` + +### Health Check + +```bash +# Check service status +docker-compose ps + +# Test backend API +curl https://your-domain.com/api/health + +# Check disk space +df -h storage/ +``` + +## ๐Ÿณ Alternative Deployment Methods + +### Using Docker Swarm + +For high availability deployments, see [Docker Swarm Setup](deploy/README.md). + +### Manual Installation + +If you prefer not to use Docker: + +1. Install Node.js 18+ +2. Install PostgreSQL +3. Clone repository +4. Install dependencies: `npm install` in both `/backend` and `/frontend` +5. Build frontend: `cd frontend && npm run build` +6. Start services with PM2 + +## ๐Ÿ“ž Support + +- ๐Ÿ“˜ [Documentation](https://github.com/the-luap/picpeak) +- ๐Ÿ› [Report Issues](https://github.com/the-luap/picpeak/issues) +- ๐Ÿ’ฌ [Discussions](https://github.com/the-luap/picpeak/discussions) + +--- + +**Need help?** Open an issue on GitHub and we'll assist you! \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5960b00 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 paul + +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. + +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/PRODUCTION_DEPLOYMENT.md b/PRODUCTION_DEPLOYMENT.md new file mode 100644 index 0000000..2f8ab13 --- /dev/null +++ b/PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,98 @@ +# Production Deployment Guide + +This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik. + +## Environment Configuration + +### Frontend Configuration + +For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain. + +1. Copy the production environment template: + ```bash + cp frontend/.env.production.example frontend/.env.production + ``` + +2. Set the API URL to use relative path: + ```env + # frontend/.env.production + VITE_API_URL=/api + ``` + + This ensures all API calls will use the same domain and protocol as the frontend. + +### Backend Configuration + +Ensure your backend `.env` file has the correct URLs: +```env +# backend/.env +FRONTEND_URL=https://yourdomain.com +ADMIN_URL=https://yourdomain.com +``` + +## Docker Compose Production + +When using Docker Compose in production: + +1. Build with production environment: + ```bash + docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production + ``` + +2. The frontend nginx configuration already includes proper proxy settings for: + - `/api` โ†’ Backend API + - `/photos` โ†’ Protected photo access + - `/thumbnails` โ†’ Thumbnail images + - `/uploads` โ†’ Public uploads (logos, favicons) + +## Traefik Configuration + +Example Traefik labels for docker-compose: + +```yaml +services: + frontend: + labels: + - "traefik.enable=true" + - "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)" + - "traefik.http.routers.picpeak.entrypoints=websecure" + - "traefik.http.routers.picpeak.tls.certresolver=letsencrypt" + - "traefik.http.services.picpeak.loadbalancer.server.port=80" +``` + +## Important Notes + +1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready. + +2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production). + +3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`. + +4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers. + +## Verification + +After deployment, verify: + +1. Check browser console for any localhost URLs (there should be none) +2. Verify all API calls use HTTPS +3. Check that images load correctly with authentication +4. Test favicon and logo display + +## Troubleshooting + +If you see console errors about localhost: + +1. Ensure `VITE_API_URL=/api` in frontend environment +2. Clear browser cache +3. Rebuild frontend with production environment: + ```bash + cd frontend + npm run build + ``` + +If images don't load: + +1. Check that nginx proxy locations are configured +2. Verify authentication tokens are being sent +3. Check backend logs for authentication errors \ No newline at end of file diff --git a/PRODUCTION_DEPLOYMENT_GUIDE.md b/PRODUCTION_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..7bf26c4 --- /dev/null +++ b/PRODUCTION_DEPLOYMENT_GUIDE.md @@ -0,0 +1,312 @@ +# Production Deployment Guide + +This guide addresses all known production deployment issues and provides solutions. + +## Pre-Deployment Checklist + +### 1. Environment Variables +Create a `.env` file with ALL required variables: + +```bash +# Required +JWT_SECRET= +DB_PASSWORD= +ADMIN_URL=https://yourdomain.com +FRONTEND_URL=https://yourdomain.com + +# Database +DB_USER=picpeak +DB_NAME=picpeak + +# Email (Optional but recommended) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +EMAIL_FROM=noreply@yourdomain.com + +# Umami Analytics (Optional) +UMAMI_URL=https://analytics.yourdomain.com +UMAMI_WEBSITE_ID=your-website-id +UMAMI_HASH_SALT= +``` + +### 2. Generate Secrets + +```bash +# Generate JWT Secret +openssl rand -base64 32 + +# Generate Database Password +openssl rand -base64 24 + +# Generate Umami Hash Salt +openssl rand -hex 32 +``` + +## Deployment Steps + +### 1. Initial Setup + +```bash +# Clone repository +git clone https://github.com/the-luap/wedding-photo-sharing.git +cd wedding-photo-sharing + +# Create required directories +mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads +mkdir -p data logs +mkdir -p certbot/conf certbot/www + +# Set permissions (important!) +chmod -R 755 storage data logs +``` + +### 2. Fix Docker Volume Permissions + +Create `docker-compose.override.yml` for local volume configuration: + +```yaml +version: '3.8' + +services: + backend: + volumes: + - ./storage:/app/storage:delegated + - ./data:/app/data:delegated + - ./logs:/app/logs:delegated + user: "1001:1001" # nodejs user + + db: + volumes: + - ./postgres-data:/var/lib/postgresql/data +``` + +### 3. Build and Deploy + +```bash +# Build images +docker-compose -f docker-compose.prod.yml build + +# Start services +docker-compose -f docker-compose.prod.yml up -d + +# Check logs +docker-compose -f docker-compose.prod.yml logs -f backend +``` + +### 4. Create Admin User + +After deployment, create the first admin user: + +```bash +# Enter backend container +docker-compose -f docker-compose.prod.yml exec backend sh + +# Create admin +node scripts/create-admin.js \ + --username admin \ + --email admin@yourdomain.com \ + --password + +# Exit container +exit +``` + +### 5. Configure Email (if using database config) + +1. Login to admin panel: https://yourdomain.com/admin +2. Go to Settings > Email Configuration +3. Enter SMTP details +4. Test email sending + +## Common Issues and Solutions + +### Issue 1: Migration Failures + +**Error**: "relation already exists" + +**Solution**: The safe migration runner handles this automatically. If issues persist: + +```bash +# Reset migrations tracking +docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak + +# In PostgreSQL: +DROP TABLE IF EXISTS migrations; +\q + +# Re-run migrations +docker-compose -f docker-compose.prod.yml exec backend npm run migrate:safe +``` + +### Issue 2: Permission Denied Errors + +**Error**: "EACCES: permission denied" + +**Solution**: Fix container permissions: + +```bash +# Stop containers +docker-compose -f docker-compose.prod.yml down + +# Fix permissions on host +sudo chown -R 1001:1001 storage data logs + +# Restart +docker-compose -f docker-compose.prod.yml up -d +``` + +### Issue 3: Database Connection Failed + +**Error**: "no pg_hba.conf entry" + +**Solution**: Already fixed in docker-compose.prod.yml with: +- SSL disabled for internal Docker network +- Proper authentication method (scram-sha-256) + +### Issue 4: Frontend Can't Connect to Backend + +**Error**: CORS errors or connection refused + +**Solution**: Ensure environment variables match: +- Backend: `FRONTEND_URL` must match your frontend URL +- Frontend: `VITE_API_URL` must be set during build + +### Issue 5: Email Not Sending + +**Solution**: Check email configuration: + +```bash +# Check backend logs +docker-compose -f docker-compose.prod.yml logs backend | grep email + +# Verify SMTP settings +# Gmail users: Use app password, not regular password +# Enable "Less secure app access" or use OAuth2 +``` + +## SSL/HTTPS Setup + +1. Update `nginx/sites-enabled/default` with your domain +2. Run certbot: + +```bash +# Initial certificate +docker-compose -f docker-compose.prod.yml run --rm certbot certonly \ + --webroot --webroot-path=/var/www/certbot \ + -d yourdomain.com -d www.yourdomain.com + +# Auto-renewal is handled by the certbot container +``` + +## Monitoring + +### Health Checks + +```bash +# Backend health +curl http://localhost/api/health + +# Database connection +docker-compose -f docker-compose.prod.yml exec backend \ + psql -U picpeak -d picpeak -c "SELECT 1" +``` + +### Logs + +```bash +# All services +docker-compose -f docker-compose.prod.yml logs -f + +# Specific service +docker-compose -f docker-compose.prod.yml logs -f backend +``` + +## Backup and Restore + +### Backup + +```bash +#!/bin/bash +# backup.sh +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="./backups/$DATE" + +mkdir -p $BACKUP_DIR + +# Database +docker-compose -f docker-compose.prod.yml exec -T db \ + pg_dump -U picpeak picpeak > $BACKUP_DIR/database.sql + +# Files +tar -czf $BACKUP_DIR/storage.tar.gz storage/ + +echo "Backup completed: $BACKUP_DIR" +``` + +### Restore + +```bash +# Database +docker-compose -f docker-compose.prod.yml exec -T db \ + psql -U picpeak picpeak < ./backups/20240713_120000/database.sql + +# Files +tar -xzf ./backups/20240713_120000/storage.tar.gz +``` + +## Production Best Practices + +1. **Always use named volumes** in production for better data persistence +2. **Set up monitoring** with Prometheus/Grafana +3. **Enable backups** with automated scripts +4. **Use a reverse proxy** (Nginx) for SSL termination +5. **Implement rate limiting** at the Nginx level +6. **Regular updates** - Keep Docker images updated +7. **Log rotation** - Configure log rotation for application logs + +## Troubleshooting Commands + +```bash +# Check running containers +docker-compose -f docker-compose.prod.yml ps + +# Restart a service +docker-compose -f docker-compose.prod.yml restart backend + +# View real-time logs +docker-compose -f docker-compose.prod.yml logs -f --tail=100 + +# Execute commands in container +docker-compose -f docker-compose.prod.yml exec backend sh + +# Database shell +docker-compose -f docker-compose.prod.yml exec db psql -U picpeak + +# Clean restart +docker-compose -f docker-compose.prod.yml down +docker-compose -f docker-compose.prod.yml up -d +``` + +## Security Checklist + +- [ ] Strong JWT_SECRET (min 32 chars) +- [ ] Strong database password +- [ ] SSL/HTTPS enabled +- [ ] Firewall configured (only 80/443 open) +- [ ] Regular security updates +- [ ] Backup encryption +- [ ] Access logs monitored +- [ ] Rate limiting enabled +- [ ] File upload restrictions configured + +## Support + +For issues not covered here: +1. Check application logs +2. Review error messages carefully +3. Ensure all environment variables are set +4. Verify file permissions +5. Check Docker daemon logs \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c94a63c --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# ๐Ÿ“ธ PicPeak - Open Source Photo Sharing for Events + +
+ PicPeak Logo + + [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) + [![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/) + [![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/) +
+ +**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding. + +![PicPeak Gallery Preview](docs/screenshot-gallery.png) + +## ๐ŸŒŸ Why Choose PicPeak? + +Unlike expensive SaaS solutions, PicPeak gives you: + +- **๐Ÿ’ฐ No Monthly Fees** - One-time setup, unlimited galleries +- **๐Ÿ”’ Complete Data Control** - Your photos stay on your server +- **๐ŸŽจ White-Label Ready** - Full branding customization +- **๐Ÿ“ฑ Mobile-First Design** - Beautiful on all devices +- **๐Ÿš€ Lightning Fast** - Optimized performance and caching +- **๐ŸŒ Multi-Language** - Built-in i18n support (EN, DE) + +## โœจ Key Features + +### For Photographers +- ๐Ÿ“ **Drag & Drop Upload** - Simply drop photos into folders +- โฐ **Auto-Expiring Galleries** - Set expiration dates (default: 30 days) +- ๐Ÿ” **Password Protection** - Secure client galleries +- ๐Ÿ“ง **Automated Emails** - Creation confirmations and expiration warnings +- ๐Ÿ“Š **Analytics Dashboard** - Track views, downloads, and engagement +- ๐ŸŽจ **Custom Themes** - Match your brand perfectly + +### For Clients +- ๐Ÿ–ผ๏ธ **Beautiful Galleries** - Clean, modern interface +- ๐Ÿ“ฑ **Mobile Optimized** - Swipe through photos on any device +- โฌ‡๏ธ **Bulk Downloads** - Download all photos with one click +- ๐Ÿ” **Smart Search** - Find photos quickly +- ๐Ÿ“ค **Guest Uploads** - Optional client photo uploads + +### Technical Excellence +- ๐Ÿณ **Docker Ready** - Deploy in minutes +- ๐Ÿ”„ **Auto-Processing** - Automatic thumbnail generation +- ๐Ÿ’พ **Smart Storage** - Automatic archiving of expired galleries +- ๐Ÿ›ก๏ธ **Security First** - JWT auth, rate limiting, CORS protection +- ๐Ÿ“ˆ **Scalable** - From small studios to large agencies + +## ๐Ÿš€ Quick Start + +Get PicPeak running in under 5 minutes: + +```bash +# Clone the repository +git clone https://github.com/the-luap/picpeak.git +cd picpeak + +# Copy environment template +cp .env.example .env + +# Edit configuration (required: JWT_SECRET) +nano .env + +# Start with Docker Compose +docker-compose up -d + +# Access at http://localhost:3005 +``` + +## ๐Ÿ“– Documentation + +- ๐Ÿ“˜ [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions +- ๐Ÿค [**Contributing**](CONTRIBUTING.md) - How to contribute +- ๐Ÿ“œ [**License**](LICENSE) - MIT License +- ๐Ÿ”’ [**Security**](SECURITY.md) - Security policies +- ๐Ÿ“‹ [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines + +## ๐ŸŽฏ Use Cases + +Perfect for: +- ๐Ÿ’’ **Wedding Photographers** - Share ceremony photos securely +- ๐ŸŽ‚ **Event Photography** - Birthday parties, corporate events +- ๐Ÿ“ธ **Portrait Studios** - Client galleries with download limits +- ๐Ÿข **Corporate Events** - Internal photo sharing with branding +- ๐ŸŽ“ **School Photography** - Secure parent access with expiration + +## ๐Ÿ—๏ธ Tech Stack + +- **Backend**: Node.js, Express, SQLite/PostgreSQL +- **Frontend**: React, Tailwind CSS, Framer Motion +- **Storage**: File-based with automatic archiving +- **Email**: SMTP with customizable templates +- **Analytics**: Privacy-focused with Umami integration + +## ๐Ÿค Contributing + +We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome. + +See our [Contributing Guide](CONTRIBUTING.md) for details. + +## ๐Ÿ“Š Comparison with Alternatives + +| Feature | PicPeak | PicDrop | Scrapbook.de | +|---------|---------|---------|--------------| +| Self-Hosted | โœ… | โŒ | โŒ | +| Custom Branding | โœ… Full | Limited | Limited | +| Monthly Cost | $0 | $29-199 | โ‚ฌ19-99 | +| Storage Limit | Unlimited* | 50-500GB | 100-1000GB | +| Client Uploads | โœ… | โœ… | โœ… | +| API Access | โœ… | Paid | โŒ | +| Open Source | โœ… | โŒ | โŒ | + +*Limited only by your server storage + +## ๐Ÿ›ก๏ธ Security + +PicPeak takes security seriously: +- ๐Ÿ” Password hashing with bcrypt +- ๐ŸŽซ JWT-based authentication +- ๐Ÿšฆ Rate limiting on all endpoints +- ๐Ÿ›ก๏ธ CORS protection +- ๐Ÿ“ Activity logging +- ๐Ÿ”’ Secure file access + +Found a security issue? Please email security@example.com + +## ๐Ÿ“ธ Screenshots + +### ๐ŸŽ›๏ธ **Admin Dashboard** +Get a complete overview of your photo galleries, analytics, and system status. + +PicPeak Admin Dashboard + +### ๐Ÿ“Š **Analytics & Insights** +Track gallery performance, view statistics, and monitor user engagement. + +PicPeak Analytics Dashboard + +### ๐Ÿ“ **Event Management** +Organize and manage your photo galleries with intuitive event management tools. + +PicPeak Events Management + +### โœจ **Key Interface Highlights** + +
+๐Ÿ‘† Click to see more interface details + +#### What makes PicPeak's interface special: + +- **๐ŸŽจ Clean Design**: Modern, photographer-friendly interface +- **๐Ÿ“ฑ Responsive**: Perfect on desktop, tablet, and mobile +- **โšก Fast Loading**: Optimized for quick photo browsing +- **๐Ÿ”’ Secure Access**: Password-protected galleries with expiration +- **๐Ÿ“ค Easy Uploads**: Drag & drop functionality for effortless photo management +- **๐ŸŽฏ Client-Focused**: Intuitive gallery experience for your clients + +
+ +## ๐Ÿ™ Acknowledgments + +PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible. + +## ๐Ÿ“„ License + +PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects. + +## ๐Ÿš€ Ready to Get Started? + +1. โญ **Star this repository** to show your support +2. ๐Ÿ“– Read the [Deployment Guide](DEPLOYMENT.md) +3. ๐Ÿ› Report issues or request features +4. ๐Ÿค Join our community and contribute! + +--- + +

+ Made with โค๏ธ by photographers, for photographers +
+ GitHub โ€ข + Documentation โ€ข + Support +

\ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3108ca5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,85 @@ +# Security Policy + +## Supported Versions + +We release patches for security vulnerabilities. Currently supported versions: + +| Version | Supported | +| ------- | ------------------ | +| 1.x.x | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps: + +### 1. **Do NOT create a public GitHub issue** + +### 2. Email us at security@example.com with: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +### 3. You can expect: +- Acknowledgment within 48 hours +- Regular updates on our progress +- Credit in the fix announcement (unless you prefer to remain anonymous) + +## Security Measures + +PicPeak implements several security measures: + +### Authentication & Authorization +- JWT-based authentication with secure token storage +- bcrypt password hashing with configurable rounds +- Role-based access control for admin functions +- Session timeout management + +### Input Validation +- All user inputs are validated and sanitized +- SQL injection prevention through parameterized queries +- XSS protection via Content Security Policy +- File upload restrictions and validation + +### Rate Limiting +- API rate limiting to prevent abuse +- Brute force protection on authentication endpoints +- Configurable limits per endpoint + +### Data Protection +- HTTPS enforcement in production +- Secure cookie settings +- CORS configuration +- Sensitive data encryption + +### Infrastructure +- Regular dependency updates +- Security headers (HSTS, X-Frame-Options, etc.) +- Activity logging for audit trails +- Automated backups + +## Best Practices for Deployment + +1. **Always use HTTPS** in production +2. **Change default passwords** immediately +3. **Keep dependencies updated** regularly +4. **Configure firewall rules** appropriately +5. **Monitor logs** for suspicious activity +6. **Backup regularly** and test restoration + +## Vulnerability Disclosure + +We believe in responsible disclosure. Once a vulnerability is fixed: + +1. We'll publish a security advisory +2. Credit researchers (with permission) +3. Detail the impact and mitigation steps +4. Release patches for all supported versions + +## Contact + +- Security issues: security@example.com +- General support: https://github.com/the-luap/picpeak/issues + +Thank you for helping keep PicPeak and its users safe! \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..e4d1265 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log +.env +storage/events/active/* +storage/events/archived/* +storage/thumbnails/* +data/*.db +logs/* +coverage +.git +.gitignore +README.md +.eslintrc.js +jest.config.js diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e4b74a7 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,41 @@ +# Backend Environment Variables Example +# Copy this file to .env and update with your values + +# Application +NODE_ENV=production +PORT=3000 + +# Security +JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long + +# URLs +ADMIN_URL=https://yourdomain.com +FRONTEND_URL=https://yourdomain.com + +# Database Configuration +DATABASE_CLIENT=pg +DB_HOST=db +DB_PORT=5432 +DB_USER=picpeak +DB_PASSWORD=your-secure-database-password +DB_NAME=picpeak + +# Email Configuration +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=your-smtp-username +SMTP_PASS=your-smtp-password +EMAIL_FROM=noreply@yourdomain.com + +# Storage Paths (Docker) +STORAGE_PATH=/app/storage +EVENTS_PATH=/app/storage/events +ARCHIVE_PATH=/app/storage/events/archived + +# Analytics (Optional) +UMAMI_URL=https://analytics.yourdomain.com +UMAMI_WEBSITE_ID=your-website-id + +# Logging +LOG_LEVEL=info \ No newline at end of file diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js new file mode 100644 index 0000000..5b22df9 --- /dev/null +++ b/backend/.eslintrc.js @@ -0,0 +1,23 @@ +module.exports = { + env: { + browser: false, + es2021: true, + node: true, + jest: true + }, + extends: [ + 'eslint:recommended' + ], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module' + }, + rules: { + 'indent': ['error', 2], + 'linebreak-style': ['error', 'unix'], + 'quotes': ['error', 'single'], + 'semi': ['error', 'always'], + 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], + 'no-console': ['warn', { allow: ['warn', 'error'] }] + } +}; diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8a27083 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,44 @@ +FROM node:18-alpine AS builder + +# Add build argument for cache busting +ARG CACHEBUST=1 + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy application files +COPY . . + +# Production stage +FROM node:18-alpine + +WORKDIR /app + +# Install dumb-init for proper signal handling and postgresql-client for database checks +RUN apk add --no-cache dumb-init postgresql-client + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 + +# Copy from builder +COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules +COPY --chown=nodejs:nodejs . . + +# Make wait script executable +RUN chmod +x wait-for-db.sh + +# Create necessary directories +RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \ + chown -R nodejs:nodejs storage data logs + +USER nodejs + +EXPOSE 3000 + +ENTRYPOINT ["dumb-init", "--"] +CMD ["./wait-for-db.sh", "node", "server.js"] diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..5858c9a --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,29 @@ +FROM node:18-alpine + +WORKDIR /app + +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init + +# Copy package files +COPY package*.json ./ + +# Install all dependencies (including dev) +RUN npm install + +# Copy application files +COPY . . + +# Create necessary directories +RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 +RUN chown -R nodejs:nodejs /app + +USER nodejs + +EXPOSE 3000 + +ENTRYPOINT ["dumb-init", "--"] +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/backend/audit-backend.json b/backend/audit-backend.json new file mode 100644 index 0000000..06cb361 --- /dev/null +++ b/backend/audit-backend.json @@ -0,0 +1,22 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": {}, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 0, + "critical": 0, + "total": 0 + }, + "dependencies": { + "prod": 329, + "dev": 307, + "optional": 54, + "peer": 1, + "peerOptional": 0, + "total": 690 + } + } +} diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db new file mode 100644 index 0000000..9e37c51 Binary files /dev/null and b/backend/data/photo_sharing.db differ diff --git a/backend/dependencies-backend.json b/backend/dependencies-backend.json new file mode 100644 index 0000000..9f84158 --- /dev/null +++ b/backend/dependencies-backend.json @@ -0,0 +1,2452 @@ +{ + "version": "1.0.11", + "name": "picpeak-backend", + "dependencies": { + "adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "overridden": false + }, + "archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "overridden": false, + "dependencies": { + "archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "overridden": false, + "dependencies": { + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "overridden": false + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "overridden": false + }, + "lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "overridden": false + }, + "lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "overridden": false + }, + "lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "overridden": false + }, + "lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "overridden": false + }, + "lodash.isplainobject": { + "version": "4.0.6" + }, + "lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "overridden": false + }, + "normalize-path": { + "version": "3.0.0" + }, + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "overridden": false + } + } + }, + "async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "overridden": false + }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "overridden": false + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "overridden": false, + "dependencies": { + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "overridden": false + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "overridden": false + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "overridden": false + } + } + }, + "readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "overridden": false, + "dependencies": { + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "overridden": false + } + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "overridden": false, + "dependencies": { + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "overridden": false + }, + "end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "overridden": false + }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "overridden": false + }, + "inherits": { + "version": "2.0.4" + }, + "readable-stream": { + "version": "3.6.2" + } + } + }, + "zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "overridden": false, + "dependencies": { + "archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "overridden": false + }, + "compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "overridden": false + }, + "readable-stream": { + "version": "3.6.2" + } + } + } + } + }, + "axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "overridden": false, + "dependencies": { + "follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "overridden": false + }, + "form-data": { + "version": "4.0.3" + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "overridden": false + } + } + }, + "bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "overridden": false, + "dependencies": { + "@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "overridden": false, + "dependencies": { + "detect-libc": { + "version": "2.0.4" + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "overridden": false + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "overridden": false + }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "overridden": false + }, + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "overridden": false + }, + "npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "overridden": false + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "overridden": false + }, + "semver": { + "version": "7.7.2" + }, + "tar": { + "version": "6.2.1" + } + } + }, + "node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "overridden": false + } + } + }, + "chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "overridden": false, + "dependencies": { + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "overridden": false, + "dependencies": { + "normalize-path": { + "version": "3.0.0" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "overridden": false + } + } + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "overridden": false, + "dependencies": { + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "overridden": false + } + } + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "overridden": false + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "overridden": false, + "dependencies": { + "is-glob": { + "version": "4.0.3" + } + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "overridden": false, + "dependencies": { + "binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "overridden": false + } + } + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "overridden": false, + "dependencies": { + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "overridden": false + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "overridden": false + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "overridden": false, + "dependencies": { + "picomatch": { + "version": "2.3.1" + } + } + } + } + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "overridden": false, + "dependencies": { + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "overridden": false + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "overridden": false + } + } + }, + "dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "overridden": false + }, + "eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "overridden": false, + "dependencies": { + "@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "overridden": false, + "dependencies": { + "eslint-visitor-keys": { + "version": "3.4.3" + }, + "eslint": { + "version": "8.57.1" + } + } + }, + "@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "overridden": false + }, + "@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "overridden": false, + "dependencies": { + "ajv": { + "version": "6.12.6" + }, + "debug": { + "version": "4.4.1" + }, + "espree": { + "version": "9.6.1" + }, + "globals": { + "version": "13.24.0" + }, + "ignore": { + "version": "5.3.2" + }, + "import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "overridden": false + }, + "js-yaml": { + "version": "4.1.0" + }, + "minimatch": { + "version": "3.1.2" + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "overridden": false + } + } + }, + "@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "overridden": false + }, + "@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "overridden": false, + "dependencies": { + "@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "overridden": false + }, + "debug": { + "version": "4.4.1" + }, + "minimatch": { + "version": "3.1.2" + } + } + }, + "@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "overridden": false + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "overridden": false, + "dependencies": { + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "overridden": false + }, + "fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "overridden": false + } + } + }, + "@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "overridden": false + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "overridden": false, + "dependencies": { + "fast-deep-equal": { + "version": "3.1.3" + }, + "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", + "overridden": false + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "overridden": false + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "overridden": false + } + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "overridden": false, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "overridden": false + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "overridden": false + } + } + }, + "cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "overridden": false, + "dependencies": { + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "overridden": false + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "overridden": false + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "overridden": false + } + } + }, + "debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "overridden": false, + "dependencies": { + "ms": { + "version": "2.1.3" + } + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "overridden": false, + "dependencies": { + "esutils": { + "version": "2.0.3" + } + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "overridden": false + }, + "eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "overridden": false, + "dependencies": { + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "overridden": false + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "overridden": false + } + } + }, + "eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "overridden": false + }, + "espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "overridden": false, + "dependencies": { + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "overridden": false + }, + "acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "overridden": false + }, + "eslint-visitor-keys": { + "version": "3.4.3" + } + } + }, + "esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "overridden": false, + "dependencies": { + "estraverse": { + "version": "5.3.0" + } + } + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "overridden": false + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "overridden": false + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "overridden": false, + "dependencies": { + "flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "overridden": false + } + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "overridden": false, + "dependencies": { + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "overridden": false + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "overridden": false + } + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "overridden": false, + "dependencies": { + "is-glob": { + "version": "4.0.3" + } + } + }, + "globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "overridden": false, + "dependencies": { + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "overridden": false + } + } + }, + "graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "overridden": false + }, + "ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "overridden": false + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "overridden": false + }, + "is-glob": { + "version": "4.0.3" + }, + "is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "overridden": false + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "overridden": false, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "overridden": false + } + } + }, + "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", + "overridden": false + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "overridden": false, + "dependencies": { + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "overridden": false + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "overridden": false + } + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "overridden": false + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "overridden": false, + "dependencies": { + "brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "overridden": false + } + } + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "overridden": false + }, + "optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "overridden": false, + "dependencies": { + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "overridden": false + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "overridden": false + }, + "levn": { + "version": "0.4.1" + }, + "prelude-ls": { + "version": "1.2.1" + }, + "type-check": { + "version": "0.4.0" + }, + "word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "overridden": false + } + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "overridden": false, + "dependencies": { + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "overridden": false + } + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "overridden": false + } + } + }, + "express-rate-limit": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz", + "overridden": false, + "dependencies": { + "express": { + "version": "4.21.2" + } + } + }, + "express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "overridden": false, + "dependencies": { + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "overridden": false + }, + "validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "overridden": false + } + } + }, + "express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "overridden": false, + "dependencies": { + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "overridden": false, + "dependencies": { + "mime-types": { + "version": "2.1.35" + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "overridden": false + } + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "overridden": false + }, + "body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "overridden": false, + "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "overridden": false + }, + "content-type": { + "version": "1.0.5" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "overridden": false + }, + "depd": { + "version": "2.0.0" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "overridden": false + }, + "http-errors": { + "version": "2.0.0" + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "overridden": false + }, + "on-finished": { + "version": "2.4.1" + }, + "qs": { + "version": "6.13.0" + }, + "raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "overridden": false + }, + "type-is": { + "version": "1.6.18" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "overridden": false + } + } + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "overridden": false, + "dependencies": { + "safe-buffer": { + "version": "5.2.1" + } + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "overridden": false + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "overridden": false + }, + "cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "overridden": false + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "overridden": false, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "overridden": false + } + } + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "overridden": false + }, + "encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "overridden": false + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "overridden": false + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "overridden": false + }, + "finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "overridden": false, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "overridden": false + }, + "encodeurl": { + "version": "2.0.0" + }, + "escape-html": { + "version": "1.0.3" + }, + "on-finished": { + "version": "2.4.1" + }, + "parseurl": { + "version": "1.3.3" + }, + "statuses": { + "version": "2.0.1" + }, + "unpipe": { + "version": "1.0.0" + } + } + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "overridden": false + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "overridden": false, + "dependencies": { + "depd": { + "version": "2.0.0" + }, + "inherits": { + "version": "2.0.4" + }, + "setprototypeof": { + "version": "1.2.0" + }, + "statuses": { + "version": "2.0.1" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "overridden": false + } + } + }, + "merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "overridden": false + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "overridden": false + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "overridden": false, + "dependencies": { + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "overridden": false + } + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "overridden": false + }, + "path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "overridden": false + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "overridden": false, + "dependencies": { + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "overridden": false + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "overridden": false + } + } + }, + "qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "overridden": false, + "dependencies": { + "side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "overridden": false + } + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "overridden": false + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "overridden": false + }, + "send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "overridden": false, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "overridden": false + }, + "depd": { + "version": "2.0.0" + }, + "destroy": { + "version": "1.2.0" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "overridden": false + }, + "escape-html": { + "version": "1.0.3" + }, + "etag": { + "version": "1.8.1" + }, + "fresh": { + "version": "0.5.2" + }, + "http-errors": { + "version": "2.0.0" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "overridden": false + }, + "ms": { + "version": "2.1.3" + }, + "on-finished": { + "version": "2.4.1" + }, + "range-parser": { + "version": "1.2.1" + }, + "statuses": { + "version": "2.0.1" + } + } + }, + "serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "overridden": false, + "dependencies": { + "encodeurl": { + "version": "2.0.0" + }, + "escape-html": { + "version": "1.0.3" + }, + "parseurl": { + "version": "1.3.3" + }, + "send": { + "version": "0.19.0" + } + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "overridden": false + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "overridden": false + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "overridden": false, + "dependencies": { + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "overridden": false + }, + "mime-types": { + "version": "2.1.35" + } + } + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "overridden": false + }, + "vary": { + "version": "1.1.2" + } + } + }, + "form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "overridden": false, + "dependencies": { + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "overridden": false + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "overridden": false, + "dependencies": { + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "overridden": false + } + } + }, + "es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "overridden": false, + "dependencies": { + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "overridden": false + }, + "get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "overridden": false + }, + "has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "overridden": false + }, + "hasown": { + "version": "2.0.2" + } + } + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "overridden": false, + "dependencies": { + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "overridden": false + } + } + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "overridden": false, + "dependencies": { + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "overridden": false + } + } + } + } + }, + "helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "overridden": false + }, + "i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "overridden": false, + "dependencies": { + "@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "overridden": false + } + } + }, + "i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "overridden": false, + "dependencies": { + "cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "overridden": false, + "dependencies": { + "node-fetch": { + "version": "2.7.0" + } + } + } + } + }, + "i18next": { + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", + "overridden": false, + "dependencies": { + "@babel/runtime": { + "version": "7.27.6" + }, + "typescript": {} + } + }, + "jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "overridden": false, + "dependencies": { + "@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "overridden": false, + "dependencies": { + "@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "overridden": false + }, + "@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "overridden": false + }, + "@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "overridden": false + }, + "@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "overridden": false + }, + "@jest/types": { + "version": "29.6.3" + }, + "@types/node": { + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "overridden": false + }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "overridden": false + }, + "chalk": { + "version": "4.1.2" + }, + "ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "overridden": false + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "overridden": false + }, + "graceful-fs": { + "version": "4.2.11" + }, + "jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "overridden": false + }, + "jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "overridden": false + }, + "jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "overridden": false + }, + "jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "overridden": false + }, + "jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "overridden": false + }, + "jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "overridden": false + }, + "jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "overridden": false + }, + "jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "overridden": false + }, + "jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "overridden": false + }, + "jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "overridden": false + }, + "jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "overridden": false + }, + "jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "overridden": false + }, + "jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "overridden": false + }, + "micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "overridden": false + }, + "node-notifier": {}, + "pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "overridden": false + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "overridden": false + }, + "strip-ansi": { + "version": "6.0.1" + } + } + }, + "@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "overridden": false, + "dependencies": { + "@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "overridden": false + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "overridden": false + }, + "@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "overridden": false + }, + "@types/node": { + "version": "24.0.10" + }, + "@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "overridden": false + }, + "chalk": { + "version": "4.1.2" + } + } + }, + "import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "overridden": false, + "dependencies": { + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "overridden": false + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "overridden": false + } + } + }, + "jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "overridden": false, + "dependencies": { + "@jest/core": { + "version": "29.7.0" + }, + "@jest/test-result": { + "version": "29.7.0" + }, + "@jest/types": { + "version": "29.6.3" + }, + "chalk": { + "version": "4.1.2" + }, + "create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "overridden": false + }, + "exit": { + "version": "0.1.2" + }, + "import-local": { + "version": "3.2.0" + }, + "jest-config": { + "version": "29.7.0" + }, + "jest-util": { + "version": "29.7.0" + }, + "jest-validate": { + "version": "29.7.0" + }, + "node-notifier": {}, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "overridden": false + } + } + }, + "node-notifier": {} + } + }, + "joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "overridden": false, + "dependencies": { + "@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "overridden": false + }, + "@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "overridden": false, + "dependencies": { + "@hapi/hoek": { + "version": "9.3.0" + } + } + }, + "@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "overridden": false, + "dependencies": { + "@hapi/hoek": { + "version": "9.3.0" + } + } + }, + "@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "overridden": false + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "overridden": false + } + } + }, + "jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "overridden": false, + "dependencies": { + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "overridden": false, + "dependencies": { + "jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "overridden": false + }, + "safe-buffer": { + "version": "5.2.1" + } + } + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "overridden": false + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "overridden": false + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "overridden": false + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "overridden": false + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "overridden": false + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "overridden": false + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "overridden": false + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "overridden": false + }, + "semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "overridden": false + } + } + }, + "knex": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/knex/-/knex-2.5.1.tgz", + "overridden": false, + "dependencies": { + "colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "overridden": false + }, + "commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "overridden": false + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "overridden": false, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "overridden": false + } + } + }, + "escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "overridden": false + }, + "esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "overridden": false + }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "overridden": false + }, + "getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "overridden": false + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "overridden": false + }, + "lodash": { + "version": "4.17.21" + }, + "pg-connection-string": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", + "overridden": false + }, + "rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "overridden": false, + "dependencies": { + "resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "overridden": false + } + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "overridden": false + }, + "tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "overridden": false + }, + "tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "overridden": false + } + } + }, + "multer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "overridden": false, + "dependencies": { + "append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "overridden": false + }, + "busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "overridden": false, + "dependencies": { + "streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "overridden": false + } + } + }, + "concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "overridden": false, + "dependencies": { + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "overridden": false + }, + "inherits": { + "version": "2.0.4" + }, + "readable-stream": { + "version": "3.6.2" + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "overridden": false + } + } + }, + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "overridden": false, + "dependencies": { + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "overridden": false + } + } + }, + "object-assign": { + "version": "4.1.1" + }, + "type-is": { + "version": "1.6.18" + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "overridden": false + } + } + }, + "node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "overridden": false, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "overridden": false + } + } + }, + "nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "overridden": false + }, + "nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "overridden": false, + "dependencies": { + "chokidar": { + "version": "3.6.0" + }, + "debug": { + "version": "4.4.1" + }, + "ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "overridden": false + }, + "minimatch": { + "version": "3.1.2" + }, + "pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "overridden": false + }, + "semver": { + "version": "7.7.2" + }, + "simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "overridden": false, + "dependencies": { + "semver": { + "version": "7.7.2" + } + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "overridden": false, + "dependencies": { + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "overridden": false + } + } + }, + "touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "overridden": false + }, + "undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "overridden": false + } + } + }, + "pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "overridden": false, + "dependencies": { + "pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "overridden": false + }, + "pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "overridden": false + }, + "pg-native": {}, + "pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "overridden": false, + "dependencies": { + "pg": { + "version": "8.16.3" + } + } + }, + "pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "overridden": false + }, + "pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "overridden": false, + "dependencies": { + "pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "overridden": false + }, + "postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "overridden": false + }, + "postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "overridden": false + }, + "postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "overridden": false + }, + "postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "overridden": false + } + } + }, + "pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "overridden": false, + "dependencies": { + "split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "overridden": false + } + } + } + } + }, + "react-i18next": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", + "overridden": false, + "dependencies": { + "@babel/runtime": { + "version": "7.27.6" + }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "overridden": false, + "dependencies": { + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "overridden": false + } + } + }, + "i18next": { + "version": "25.3.1" + }, + "react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "overridden": false + }, + "typescript": {} + } + }, + "sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "overridden": false, + "dependencies": { + "color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "overridden": false, + "dependencies": { + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "overridden": false + }, + "color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "overridden": false + } + } + }, + "detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "overridden": false + }, + "node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "overridden": false + }, + "prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "overridden": false, + "dependencies": { + "detect-libc": { + "version": "2.0.4" + }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "overridden": false + }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "overridden": false + }, + "minimist": { + "version": "1.2.8" + }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "overridden": false + }, + "napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "overridden": false + }, + "node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "overridden": false + }, + "pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "overridden": false + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "overridden": false + }, + "simple-get": { + "version": "4.0.1" + }, + "tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "overridden": false + }, + "tunnel-agent": { + "version": "0.6.0" + } + } + }, + "semver": { + "version": "7.7.2" + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "overridden": false, + "dependencies": { + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "overridden": false + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "overridden": false + }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "overridden": false + } + } + }, + "tar-fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "overridden": false, + "dependencies": { + "bare-fs": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", + "overridden": false + }, + "bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "overridden": false + }, + "pump": { + "version": "3.0.3" + }, + "tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "overridden": false + } + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "overridden": false, + "dependencies": { + "safe-buffer": { + "version": "5.2.1" + } + } + } + } + }, + "sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "overridden": false, + "dependencies": { + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "overridden": false, + "dependencies": { + "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", + "overridden": false + } + } + }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "overridden": false + }, + "node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "overridden": false, + "dependencies": { + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "overridden": false + }, + "glob": { + "version": "7.2.3" + }, + "graceful-fs": { + "version": "4.2.11" + }, + "make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "overridden": false + }, + "nopt": { + "version": "5.0.0" + }, + "npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "overridden": false + }, + "rimraf": { + "version": "3.0.2" + }, + "semver": { + "version": "7.7.2" + }, + "tar": { + "version": "6.2.1" + }, + "which": { + "version": "2.0.2" + } + } + }, + "prebuild-install": { + "version": "7.1.3" + }, + "tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "overridden": false, + "dependencies": { + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "overridden": false + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "overridden": false + }, + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "overridden": false + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "overridden": false + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "overridden": false + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "overridden": false + } + } + } + } + }, + "supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "overridden": false, + "dependencies": { + "methods": { + "version": "1.1.2" + }, + "superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "overridden": false, + "dependencies": { + "component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "overridden": false + }, + "cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "overridden": false + }, + "debug": { + "version": "4.4.1" + }, + "fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "overridden": false + }, + "form-data": { + "version": "4.0.3" + }, + "formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "overridden": false + }, + "methods": { + "version": "1.1.2" + }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "overridden": false + }, + "qs": { + "version": "6.13.0" + }, + "semver": { + "version": "7.7.2" + } + } + } + } + }, + "uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "overridden": false + }, + "winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "overridden": false, + "dependencies": { + "@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "overridden": false + }, + "@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "overridden": false, + "dependencies": { + "colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "overridden": false + }, + "enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "overridden": false + }, + "kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "overridden": false + } + } + }, + "async": { + "version": "3.2.6" + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "overridden": false + }, + "logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "overridden": false, + "dependencies": { + "@colors/colors": { + "version": "1.6.0" + }, + "@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "overridden": false + }, + "fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "overridden": false + }, + "ms": { + "version": "2.1.3" + }, + "safe-stable-stringify": { + "version": "2.5.0" + }, + "triple-beam": { + "version": "1.4.1" + } + } + }, + "one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "overridden": false, + "dependencies": { + "fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "overridden": false + } + } + }, + "readable-stream": { + "version": "3.6.2" + }, + "safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "overridden": false + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "overridden": false + }, + "triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "overridden": false + }, + "winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "overridden": false, + "dependencies": { + "logform": { + "version": "2.7.0" + }, + "readable-stream": { + "version": "3.6.2" + }, + "triple-beam": { + "version": "1.4.1" + } + } + } + } + }, + "zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "overridden": false + } + } +} diff --git a/backend/docs/SECURITY_LOGGING.md b/backend/docs/SECURITY_LOGGING.md new file mode 100644 index 0000000..1cce43d --- /dev/null +++ b/backend/docs/SECURITY_LOGGING.md @@ -0,0 +1,161 @@ +# Security Logging Documentation + +## Overview +This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities. + +## Log Files + +### 1. **security.log** +- Location: `logs/security.log` +- Contains: All security-related events (authentication, rate limiting, suspicious activity) +- Max Size: 20MB with rotation (keeps 10 files) +- Format: JSON with timestamp + +### 2. **error.log** +- Location: `logs/error.log` +- Contains: All error-level logs including auth failures +- Max Size: 10MB with rotation (keeps 5 files) + +### 3. **combined.log** +- Location: `logs/combined.log` +- Contains: All logs (info, warn, error) +- Max Size: 50MB with rotation (keeps 10 files) + +## Security Events Logged + +### Rate Limiting +When rate limits are exceeded, the following is logged: +```json +{ + "timestamp": "2024-01-18 14:23:45.123", + "level": "warn", + "message": "Rate limit exceeded", + "security": true, + "ip": "192.168.1.1", + "path": "/api/admin/login", + "method": "POST", + "authenticated": false, + "userAgent": "Mozilla/5.0...", + "referer": "https://app.example.com", + "origin": "https://app.example.com", + "headers": { + "x-forwarded-for": "192.168.1.1", + "x-real-ip": "192.168.1.1" + }, + "requestUrl": "/api/admin/login", + "rateLimitInfo": { + "limit": 5, + "current": 6, + "remaining": 0, + "resetTime": "2024-01-18T14:38:45.123Z" + } +} +``` + +### Authentication Failures + +#### Admin Login Failures +- Tracked in `login_attempts` table +- Logged with: IP address, username, user agent, timestamp +- Account lockout after 5 failures in 15 minutes + +#### Gallery Password Failures +- Tracked in `access_logs` table with action='login_fail' +- Logged with: event_id, IP address, user agent +- Gallery lockout after 5 failures in 15 minutes + +### JWT Validation Failures +```json +{ + "timestamp": "2024-01-18 14:23:45.123", + "level": "warn", + "message": "JWT validation failed", + "ip": "192.168.1.1", + "path": "/api/admin/events", + "method": "GET", + "userAgent": "Mozilla/5.0...", + "error": "TokenExpiredError", + "message": "jwt expired" +} +``` + +### Suspicious Activity +- Multiple IPs attempting login for same account +- Token usage from different IP than issued +- Token usage after password change +- Revoked token usage attempts + +## Configuration Settings + +All rate limiting settings are configurable via the admin panel: + +| Setting | Default | Range | Description | +|---------|---------|-------|-------------| +| rate_limit_enabled | true | - | Enable/disable rate limiting | +| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit | +| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints | +| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints | +| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests | +| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints | + +## Database Tables + +### login_attempts +```sql +- id +- username +- ip_address +- user_agent +- success (boolean) +- created_at +``` + +### access_logs +```sql +- id +- event_id +- ip_address +- user_agent +- action ('view', 'download', 'login_success', 'login_fail') +- photo_id (nullable) +- created_at +``` + +## Environment Variables + +- `LOG_LEVEL`: Set logging level (default: 'info') +- `LOG_TO_CONSOLE`: Enable console logging in production (default: false) + +## Monitoring Recommendations + +1. **Set up alerts for:** + - Rate limit exceeded events (possible DDoS) + - Multiple failed login attempts from same IP + - Account lockout events + - JWT validation failures spike + +2. **Regular review:** + - Check security.log for patterns + - Review login_attempts table for brute force attempts + - Monitor access_logs for suspicious gallery access patterns + +3. **Log analysis tools:** + - Use log aggregation tools (ELK stack, Splunk) + - Set up dashboards for security metrics + - Configure alerts for threshold breaches + +## Production Deployment Notes + +1. Ensure logs directory has proper permissions +2. Set up log rotation outside of application if needed +3. Consider shipping logs to centralized logging service +4. Monitor disk space for log files +5. Set `LOG_TO_CONSOLE=true` for container deployments + +## Security Best Practices + +1. Never log sensitive data (passwords, tokens) +2. Use generic error messages to prevent user enumeration +3. Clean up old login attempts regularly (7 days retention) +4. Monitor for unusual patterns in real-time +5. Keep rate limit settings appropriate for your usage \ No newline at end of file diff --git a/backend/ecosystem.config.js b/backend/ecosystem.config.js new file mode 100644 index 0000000..e95827e --- /dev/null +++ b/backend/ecosystem.config.js @@ -0,0 +1,21 @@ +module.exports = { + apps: [{ + name: 'picpeak', + script: './server.js', + instances: 'max', + exec_mode: 'cluster', + env: { + NODE_ENV: 'production', + PORT: 3000 + }, + error_file: './logs/pm2-error.log', + out_file: './logs/pm2-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss', + max_memory_restart: '1G', + watch: false, + ignore_watch: ['node_modules', 'logs', 'storage'], + wait_ready: true, + listen_timeout: 3000, + kill_timeout: 5000 + }] +}; diff --git a/backend/init-production.sh b/backend/init-production.sh new file mode 100755 index 0000000..d3fb599 --- /dev/null +++ b/backend/init-production.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# init-production.sh - Production initialization script + +set -e + +echo "๐Ÿš€ Initializing PicPeak Production Environment..." + +# Wait for services to be ready +echo "โณ Waiting for database to be fully ready..." +sleep 3 + +# Fix permissions if running as root (shouldn't happen with proper Dockerfile) +if [ "$(id -u)" = "0" ]; then + echo "๐Ÿ”ง Fixing file permissions..." + chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null || true +fi + +# Create required directories +echo "๐Ÿ“ Creating required directories..." +mkdir -p /app/storage/events/active \ + /app/storage/events/archived \ + /app/storage/thumbnails \ + /app/storage/uploads/logos \ + /app/storage/uploads/favicons \ + /app/data \ + /app/logs + +# Run migrations with safe runner +echo "๐Ÿ—„๏ธ Running database migrations (safe mode)..." +NODE_ENV=production npm run migrate:safe + +# Create admin user if environment variables are set +if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then + echo "๐Ÿ‘ค Creating admin user..." + node scripts/create-admin.js \ + --email "$ADMIN_EMAIL" \ + --username "${ADMIN_USERNAME:-admin}" \ + --password "$ADMIN_PASSWORD" || echo "Admin user might already exist" +fi + +# Initialize email configuration if variables are set +if [ -n "$SMTP_HOST" ]; then + echo "๐Ÿ“ง Email configuration detected via environment variables" +fi + +echo "โœ… Production initialization complete!" +echo "๐ŸŒ Starting application server..." + +# Start the application +exec node server.js \ No newline at end of file diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..2c28c3d --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + testEnvironment: 'node', + coverageDirectory: 'coverage', + collectCoverageFrom: [ + 'src/**/*.js', + '!src/**/*.test.js' + ], + testMatch: [ + '**/__tests__/**/*.test.js' + ], + setupFilesAfterEnv: ['/jest.setup.js'] +}; diff --git a/backend/jest.setup.js b/backend/jest.setup.js new file mode 100644 index 0000000..639c93a --- /dev/null +++ b/backend/jest.setup.js @@ -0,0 +1,4 @@ +beforeAll(() => { + process.env.NODE_ENV = 'test'; + process.env.JWT_SECRET = 'test-secret'; +}); diff --git a/backend/knexfile.js b/backend/knexfile.js new file mode 100644 index 0000000..598d4fb --- /dev/null +++ b/backend/knexfile.js @@ -0,0 +1,59 @@ +require('dotenv').config(); + +const path = require('path'); + +// Database configuration for different environments +const config = { + development: { + client: process.env.DATABASE_CLIENT || 'sqlite3', + connection: process.env.DATABASE_CLIENT === 'pg' ? { + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_NAME || 'photo_sharing' + } : { + filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db') + }, + useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg', + migrations: { + directory: './migrations' + }, + seeds: { + directory: './seeds' + } + }, + + production: { + client: process.env.DATABASE_CLIENT || 'pg', + connection: { + host: process.env.DB_HOST || 'db', + port: process.env.DB_PORT || 5432, + user: process.env.DB_USER || 'picpeak', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'picpeak', + ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, + // Connection stability settings + connectionTimeoutMillis: 30000, + idleTimeoutMillis: 30000, + keepAlive: true, + keepAliveInitialDelayMillis: 0 + }, + pool: { + min: 2, + max: 10, + acquireTimeoutMillis: 30000, + createTimeoutMillis: 30000, + idleTimeoutMillis: 30000, + reapIntervalMillis: 1000, + createRetryIntervalMillis: 200, + propagateCreateError: false + }, + migrations: { + directory: './migrations' + }, + acquireConnectionTimeout: 60000 + } +}; + +module.exports = config[process.env.NODE_ENV || 'development']; \ No newline at end of file diff --git a/backend/migrations/004_add_categories_and_cms.js b/backend/migrations/004_add_categories_and_cms.js new file mode 100644 index 0000000..e77a8d5 --- /dev/null +++ b/backend/migrations/004_add_categories_and_cms.js @@ -0,0 +1,101 @@ +const { db } = require('../src/database/db'); + +async function up() { + console.log('Adding photo categories and CMS tables...'); + + // Create photo_categories table + await db.schema.createTable('photo_categories', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + table.string('slug', 100).notNullable(); + table.boolean('is_global').defaultTo(true); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(db.fn.now()); + + // Unique constraint for slug within event scope + table.unique(['slug', 'event_id']); + }); + + // Create cms_pages table + await db.schema.createTable('cms_pages', (table) => { + table.increments('id').primary(); + table.string('slug', 100).unique().notNullable(); + table.text('title_en'); + table.text('title_de'); + table.text('content_en'); + table.text('content_de'); + table.timestamp('updated_at').defaultTo(db.fn.now()); + }); + + // Add category_id to photos table + await db.schema.alterTable('photos', (table) => { + table.integer('category_id').references('id').inTable('photo_categories'); + }); + + // Add language preference to admin_users + await db.schema.alterTable('admin_users', (table) => { + table.string('language', 2).defaultTo('en'); + }); + + // Add language preference to app_settings for global default + await db('app_settings').insert({ + setting_key: 'default_language', + setting_value: 'en', + setting_type: 'general', + updated_at: new Date() + }); + + // Insert default global categories + const defaultCategories = [ + { name: 'Ceremony', slug: 'ceremony', is_global: true }, + { name: 'Reception', slug: 'reception', is_global: true }, + { name: 'Portraits', slug: 'portraits', is_global: true }, + { name: 'Group Photos', slug: 'group-photos', is_global: true }, + { name: 'Details', slug: 'details', is_global: true }, + { name: 'Party', slug: 'party', is_global: true } + ]; + + await db('photo_categories').insert(defaultCategories); + + // Insert default legal pages + await db('cms_pages').insert([ + { + slug: 'impressum', + title_en: 'Legal Notice', + title_de: 'Impressum', + content_en: '

Legal Notice

Please edit this content in the admin panel.

', + content_de: '

Impressum

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date() + }, + { + slug: 'datenschutz', + title_en: 'Privacy Policy', + title_de: 'Datenschutzerklรคrung', + content_en: '

Privacy Policy

Please edit this content in the admin panel.

', + content_de: '

Datenschutzerklรคrung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date() + } + ]); + + console.log('Photo categories and CMS tables created successfully'); +} + +async function down() { + // Remove language from app_settings + await db('app_settings').where('setting_key', 'default_language').delete(); + + // Drop columns + await db.schema.alterTable('admin_users', (table) => { + table.dropColumn('language'); + }); + + await db.schema.alterTable('photos', (table) => { + table.dropColumn('category_id'); + }); + + // Drop tables + await db.schema.dropTableIfExists('cms_pages'); + await db.schema.dropTableIfExists('photo_categories'); +} + +module.exports = { up, down }; \ No newline at end of file diff --git a/backend/migrations/006_add_photo_counter_to_categories.js b/backend/migrations/006_add_photo_counter_to_categories.js new file mode 100644 index 0000000..6f378aa --- /dev/null +++ b/backend/migrations/006_add_photo_counter_to_categories.js @@ -0,0 +1,28 @@ +exports.up = async function(knex) { + // Add photo_counter column to photo_categories table + await knex.schema.alterTable('photo_categories', function(table) { + table.integer('photo_counter').defaultTo(0).notNullable(); + }); + + // Initialize counters based on existing photos + const categories = await knex('photo_categories').select('id'); + + for (const category of categories) { + const photoCount = await knex('photos') + .where('category_id', category.id) + .count('id as count') + .first(); + + if (photoCount && photoCount.count > 0) { + await knex('photo_categories') + .where('id', category.id) + .update({ photo_counter: photoCount.count }); + } + } +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('photo_categories', function(table) { + table.dropColumn('photo_counter'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/007_add_read_at_to_activity_logs.js b/backend/migrations/007_add_read_at_to_activity_logs.js new file mode 100644 index 0000000..d4333bd --- /dev/null +++ b/backend/migrations/007_add_read_at_to_activity_logs.js @@ -0,0 +1,15 @@ +exports.up = async function(knex) { + // Add read_at column to activity_logs table + const hasReadAt = await knex.schema.hasColumn('activity_logs', 'read_at'); + if (!hasReadAt) { + await knex.schema.table('activity_logs', (table) => { + table.datetime('read_at').nullable(); + }); + } +}; + +exports.down = async function(knex) { + await knex.schema.table('activity_logs', (table) => { + table.dropColumn('read_at'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/008_add_language_support_to_email_templates.js b/backend/migrations/008_add_language_support_to_email_templates.js new file mode 100644 index 0000000..4071850 --- /dev/null +++ b/backend/migrations/008_add_language_support_to_email_templates.js @@ -0,0 +1,35 @@ +exports.up = async function(knex) { + // Add language-specific columns to email_templates + await knex.schema.alterTable('email_templates', function(table) { + // Add English versions (rename existing columns for consistency) + table.renameColumn('subject', 'subject_en'); + table.renameColumn('body_html', 'body_html_en'); + table.renameColumn('body_text', 'body_text_en'); + + // Add German versions + table.string('subject_de'); + table.text('body_html_de'); + table.text('body_text_de'); + }); + + // Copy existing values to German columns as defaults + await knex('email_templates').update({ + subject_de: knex.raw('subject_en'), + body_html_de: knex.raw('body_html_en'), + body_text_de: knex.raw('body_text_en') + }); +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('email_templates', function(table) { + // Remove German columns + table.dropColumn('subject_de'); + table.dropColumn('body_html_de'); + table.dropColumn('body_text_de'); + + // Rename columns back + table.renameColumn('subject_en', 'subject'); + table.renameColumn('body_html_en', 'body_html'); + table.renameColumn('body_text_en', 'body_text'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/009_update_german_email_templates.js b/backend/migrations/009_update_german_email_templates.js new file mode 100644 index 0000000..9c31403 --- /dev/null +++ b/backend/migrations/009_update_german_email_templates.js @@ -0,0 +1,67 @@ +exports.up = async function(knex) { + // Update gallery_created template with German content + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + subject_de: 'Ihre Fotogalerie ist bereit!', + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+

Galerie-Details:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Galerie-Link: {{gallery_link}}
  • +
  • Passwort: {{gallery_password}}
  • +
  • Gรผltig bis: {{expiry_date}}
  • +
+

Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit sie Fotos ansehen und herunterladen kรถnnen.

`, + body_text_de: 'Galerie erfolgreich erstellt\n\nLiebe(r) {{host_name}},\n\nIhre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!' + }); + + // Update expiration_warning template with German content + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + subject_de: 'Ihre Fotogalerie lรคuft bald ab', + body_html_de: `

Galerie lรคuft bald ab

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" lรคuft in {{days_remaining}} Tagen ab.

+

Nach Ablauf wird die Galerie archiviert und ist fรผr Gรคste nicht mehr zugรคnglich.

+

Galerie besuchen

`, + body_text_de: 'Galerie lรคuft bald ab\n\nLiebe(r) {{host_name}},\n\nIhre Fotogalerie "{{event_name}}" lรคuft in {{days_remaining}} Tagen ab.' + }); + + // Update gallery_expired template if it exists + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + subject_de: 'Ihre Fotogalerie {{event_name}} ist abgelaufen', + body_html_de: `

Galerie abgelaufen

+

Ihre Fotogalerie fรผr "{{event_name}}" ist abgelaufen und nicht mehr zugรคnglich.

+

Die Fotos wurden zur sicheren Aufbewahrung archiviert. Wenn Sie wieder Zugriff benรถtigen, wenden Sie sich bitte an den Administrator unter {{admin_email}}.

+

Vielen Dank fรผr die Nutzung unseres Foto-Sharing-Services!

+

Mit freundlichen GrรผรŸen,
Das Foto-Sharing-Team

`, + body_text_de: 'Ihre Fotogalerie fรผr {{event_name}} ist abgelaufen und nicht mehr zugรคnglich.\n\nDie Fotos wurden archiviert. Bei Bedarf kontaktieren Sie bitte den Administrator unter {{admin_email}}.' + }); + + // Update archive_complete template if it exists + await knex('email_templates') + .where('template_key', 'archive_complete') + .update({ + subject_de: 'Archivierung abgeschlossen: {{event_name}}', + body_html_de: `

Archivierung abgeschlossen

+

Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.

+

ArchivgrรถรŸe: {{archive_size}}

+

Das Archiv wird sicher aufbewahrt und kann bei Bedarf wiederhergestellt werden.

`, + body_text_de: 'Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.\n\nArchivgrรถรŸe: {{archive_size}}' + }); +}; + +exports.down = async function(knex) { + // Reset German fields to null + await knex('email_templates').update({ + subject_de: null, + body_html_de: null, + body_text_de: null + }); +}; \ No newline at end of file diff --git a/backend/migrations/010_add_missing_email_templates.js b/backend/migrations/010_add_missing_email_templates.js new file mode 100644 index 0000000..c67727f --- /dev/null +++ b/backend/migrations/010_add_missing_email_templates.js @@ -0,0 +1,57 @@ +exports.up = async function(knex) { + // Check if gallery_expired template exists + const galleryExpiredExists = await knex('email_templates') + .where('template_key', 'gallery_expired') + .first(); + + if (!galleryExpiredExists) { + await knex('email_templates').insert({ + template_key: 'gallery_expired', + subject_en: 'Your {{event_name}} photo gallery has expired', + body_html_en: `

Gallery Expired

+

Your photo gallery for {{event_name}} has expired and is no longer accessible.

+

The photos have been archived for safekeeping. If you need access to them, please contact the event administrator at {{admin_email}}.

+

Thank you for using our photo sharing service!

+

Best regards,
The Photo Sharing Team

`, + body_text_en: 'Your photo gallery for {{event_name}} has expired and is no longer accessible.\n\nThe photos have been archived. Please contact the administrator at {{admin_email}} if you need access.', + subject_de: 'Ihre Fotogalerie {{event_name}} ist abgelaufen', + body_html_de: `

Galerie abgelaufen

+

Ihre Fotogalerie fรผr "{{event_name}}" ist abgelaufen und nicht mehr zugรคnglich.

+

Die Fotos wurden zur sicheren Aufbewahrung archiviert. Wenn Sie wieder Zugriff benรถtigen, wenden Sie sich bitte an den Administrator unter {{admin_email}}.

+

Vielen Dank fรผr die Nutzung unseres Foto-Sharing-Services!

+

Mit freundlichen GrรผรŸen,
Das Foto-Sharing-Team

`, + body_text_de: 'Ihre Fotogalerie fรผr {{event_name}} ist abgelaufen und nicht mehr zugรคnglich.\n\nDie Fotos wurden archiviert. Bei Bedarf kontaktieren Sie bitte den Administrator unter {{admin_email}}.', + variables: JSON.stringify(['event_name', 'admin_email']) + }); + } + + // Check if archive_complete template exists + const archiveCompleteExists = await knex('email_templates') + .where('template_key', 'archive_complete') + .first(); + + if (!archiveCompleteExists) { + await knex('email_templates').insert({ + template_key: 'archive_complete', + subject_en: 'Archive Complete: {{event_name}}', + body_html_en: `

Archive Complete

+

The photo gallery "{{event_name}}" has been successfully archived.

+

Archive size: {{archive_size}}

+

The archive has been stored securely and can be restored if needed.

`, + body_text_en: 'The photo gallery "{{event_name}}" has been successfully archived.\n\nArchive size: {{archive_size}}', + subject_de: 'Archivierung abgeschlossen: {{event_name}}', + body_html_de: `

Archivierung abgeschlossen

+

Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.

+

ArchivgrรถรŸe: {{archive_size}}

+

Das Archiv wird sicher aufbewahrt und kann bei Bedarf wiederhergestellt werden.

`, + body_text_de: 'Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.\n\nArchivgrรถรŸe: {{archive_size}}', + variables: JSON.stringify(['event_name', 'archive_size']) + }); + } +}; + +exports.down = async function(knex) { + await knex('email_templates') + .whereIn('template_key', ['gallery_expired', 'archive_complete']) + .del(); +}; \ No newline at end of file diff --git a/backend/migrations/011_add_user_upload_settings.js b/backend/migrations/011_add_user_upload_settings.js new file mode 100644 index 0000000..f347cca --- /dev/null +++ b/backend/migrations/011_add_user_upload_settings.js @@ -0,0 +1,23 @@ +exports.up = async function(knex) { + // Add user upload settings to events table + await knex.schema.alterTable('events', function(table) { + table.boolean('allow_user_uploads').defaultTo(false); + table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL'); + }); + + // Add uploaded_by field to photos table to track who uploaded + await knex.schema.alterTable('photos', function(table) { + table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier + }); +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('allow_user_uploads'); + table.dropColumn('upload_category_id'); + }); + + await knex.schema.alterTable('photos', function(table) { + table.dropColumn('uploaded_by'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/012_add_hero_photo_id.js b/backend/migrations/012_add_hero_photo_id.js new file mode 100644 index 0000000..608d75d --- /dev/null +++ b/backend/migrations/012_add_hero_photo_id.js @@ -0,0 +1,15 @@ +exports.up = async function(knex) { + // Add hero_photo_id to events table + const hasColumn = await knex.schema.hasColumn('events', 'hero_photo_id'); + if (!hasColumn) { + await knex.schema.alterTable('events', function(table) { + table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); + }); + } +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('hero_photo_id'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/013_fix_email_links_and_date_format.js b/backend/migrations/013_fix_email_links_and_date_format.js new file mode 100644 index 0000000..fa12478 --- /dev/null +++ b/backend/migrations/013_fix_email_links_and_date_format.js @@ -0,0 +1,112 @@ +exports.up = async function(knex) { + // First, add date format configuration to app_settings table + const dateFormatSetting = await knex('app_settings').where('setting_key', 'general_date_format').first(); + if (!dateFormatSetting) { + await knex('app_settings').insert({ + setting_key: 'general_date_format', + setting_value: JSON.stringify({ + format: 'DD/MM/YYYY', // European format as default + locale: 'en-GB' + }), + setting_type: 'general', + updated_at: new Date() + }); + } + + // Update English email templates to use proper HTML links + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + body_html_en: `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created!

+

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Gallery Link: {{gallery_link}}
  • +
  • Password: {{gallery_password}}
  • +
  • Valid Until: {{expiry_date}}
  • +
+

Share this link and password with your guests so they can view and download photos.

+

View Gallery

`, + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+

Galerie-Details:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Galerie-Link: {{gallery_link}}
  • +
  • Passwort: {{gallery_password}}
  • +
  • Gรผltig bis: {{expiry_date}}
  • +
+

Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit sie Fotos ansehen und herunterladen kรถnnen.

+

Galerie anzeigen

` + }); + + // Update expiration warning template + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + body_html_en: `

Gallery Expiring Soon

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.

+

After expiration, the gallery will be archived and no longer accessible to guests.

+

Visit Gallery

+

Gallery Link: {{gallery_link}}

`, + body_html_de: `

Galerie lรคuft bald ab

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" lรคuft in {{days_remaining}} Tagen ab.

+

Nach Ablauf wird die Galerie archiviert und ist fรผr Gรคste nicht mehr zugรคnglich.

+

Galerie besuchen

+

Galerie-Link: {{gallery_link}}

` + }); + + // Update gallery expired template + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + body_html_en: `

Gallery Expired

+

Your photo gallery for "{{event_name}}" has expired and is no longer accessible.

+

The photos have been safely archived. If you need access again, please contact the administrator at {{admin_email}}.

+

Thank you for using our photo sharing service!

+

Best regards,
The Photo Sharing Team

`, + body_html_de: `

Galerie abgelaufen

+

Ihre Fotogalerie fรผr "{{event_name}}" ist abgelaufen und nicht mehr zugรคnglich.

+

Die Fotos wurden zur sicheren Aufbewahrung archiviert. Wenn Sie wieder Zugriff benรถtigen, wenden Sie sich bitte an den Administrator unter {{admin_email}}.

+

Vielen Dank fรผr die Nutzung unseres Foto-Sharing-Services!

+

Mit freundlichen GrรผรŸen,
Das Foto-Sharing-Team

` + }); +}; + +exports.down = async function(knex) { + // Remove date format setting + await knex('app_settings').where('setting_key', 'general_date_format').del(); + + // Revert email templates to plain text links + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + body_html_en: `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created!

+

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Gallery Link: {{gallery_link}}
  • +
  • Password: {{gallery_password}}
  • +
  • Valid Until: {{expiry_date}}
  • +
+

Share this link and password with your guests so they can view and download photos.

`, + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+

Galerie-Details:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Galerie-Link: {{gallery_link}}
  • +
  • Passwort: {{gallery_password}}
  • +
  • Gรผltig bis: {{expiry_date}}
  • +
+

Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit sie Fotos ansehen und herunterladen kรถnnen.

` + }); +}; \ No newline at end of file diff --git a/backend/migrations/014_add_default_welcome_message.js b/backend/migrations/014_add_default_welcome_message.js new file mode 100644 index 0000000..37e4bc2 --- /dev/null +++ b/backend/migrations/014_add_default_welcome_message.js @@ -0,0 +1,130 @@ +exports.up = async function(knex) { + // Add default welcome message to app_settings + const existingSetting = await knex('app_settings') + .where('setting_key', 'general_default_welcome_message') + .first(); + + if (!existingSetting) { + await knex('app_settings').insert({ + setting_key: 'general_default_welcome_message', + setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'), + setting_type: 'general', + updated_at: new Date() + }); + } + + // Update the gallery_created email template to ensure it has the welcome_message placeholder + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + body_html_en: `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created!

+{{#if welcome_message}} +
+

Personal Message:

+

{{welcome_message}}

+
+{{/if}} +

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Gallery Link: {{gallery_link}}
  • +
  • Password: {{gallery_password}}
  • +
  • Valid Until: {{expiry_date}}
  • +
+

Share this link and password with your guests so they can view and download photos.

+

View Gallery

`, + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+{{#if welcome_message}} +
+

Persรถnliche Nachricht:

+

{{welcome_message}}

+
+{{/if}} +

Galerie-Details:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Galerie-Link: {{gallery_link}}
  • +
  • Passwort: {{gallery_password}}
  • +
  • Gรผltig bis: {{expiry_date}}
  • +
+

Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit sie Fotos ansehen und herunterladen kรถnnen.

+

Galerie anzeigen

`, + body_text_en: `Gallery Successfully Created + +Dear {{host_name}}, + +Your photo gallery "{{event_name}}" has been successfully created! + +{{#if welcome_message}} +Personal Message: +{{welcome_message}} + +{{/if}} +Gallery Details: +- Event Date: {{event_date}} +- Gallery Link: {{gallery_link}} +- Password: {{gallery_password}} +- Valid Until: {{expiry_date}} + +Share this link and password with your guests so they can view and download photos.`, + body_text_de: `Galerie erfolgreich erstellt + +Liebe(r) {{host_name}}, + +Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt! + +{{#if welcome_message}} +Persรถnliche Nachricht: +{{welcome_message}} + +{{/if}} +Galerie-Details: +- Veranstaltungsdatum: {{event_date}} +- Galerie-Link: {{gallery_link}} +- Passwort: {{gallery_password}} +- Gรผltig bis: {{expiry_date}} + +Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit sie Fotos ansehen und herunterladen kรถnnen.` + }); +}; + +exports.down = async function(knex) { + // Remove the default welcome message setting + await knex('app_settings') + .where('setting_key', 'general_default_welcome_message') + .del(); + + // Revert email templates + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + body_html_en: `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created!

+

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Gallery Link: {{gallery_link}}
  • +
  • Password: {{gallery_password}}
  • +
  • Valid Until: {{expiry_date}}
  • +
+

Share this link and password with your guests so they can view and download photos.

+

View Gallery

`, + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+

Galerie-Details:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Galerie-Link: {{gallery_link}}
  • +
  • Passwort: {{gallery_password}}
  • +
  • Gรผltig bis: {{expiry_date}}
  • +
+

Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit sie Fotos ansehen und herunterladen kรถnnen.

+

Galerie anzeigen

` + }); +}; \ No newline at end of file diff --git a/backend/migrations/014_add_host_name_to_events.js b/backend/migrations/014_add_host_name_to_events.js new file mode 100644 index 0000000..259eda2 --- /dev/null +++ b/backend/migrations/014_add_host_name_to_events.js @@ -0,0 +1,35 @@ +const { db } = require('../src/database/db'); + +async function up() { + // Check if host_name column already exists + const hasHostName = await db.schema.hasColumn('events', 'host_name'); + + if (!hasHostName) { + await db.schema.table('events', (table) => { + table.string('host_name').after('event_date'); + }); + + console.log('Added host_name column to events table'); + } +} + +async function down() { + await db.schema.table('events', (table) => { + table.dropColumn('host_name'); + }); +} + +module.exports = { up, down }; + +// Run migration if called directly +if (require.main === module) { + up() + .then(() => { + console.log('Migration completed successfully'); + process.exit(0); + }) + .catch((error) => { + console.error('Migration failed:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/backend/migrations/015_add_login_attempts_table.js b/backend/migrations/015_add_login_attempts_table.js new file mode 100644 index 0000000..fa5f328 --- /dev/null +++ b/backend/migrations/015_add_login_attempts_table.js @@ -0,0 +1,19 @@ +exports.up = function(knex) { + return knex.schema.createTable('login_attempts', table => { + table.increments('id').primary(); + table.string('identifier').notNullable(); // username or email + table.string('ip_address', 45).notNullable(); // IPv4 or IPv6 + table.text('user_agent'); + table.timestamp('attempt_time').defaultTo(knex.fn.now()); + table.boolean('success').defaultTo(false); + + // Indexes for performance + table.index('identifier'); + table.index('attempt_time'); + table.index(['identifier', 'success', 'attempt_time']); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('login_attempts'); +}; \ No newline at end of file diff --git a/backend/migrations/016_add_auth_security_columns.js b/backend/migrations/016_add_auth_security_columns.js new file mode 100644 index 0000000..f938247 --- /dev/null +++ b/backend/migrations/016_add_auth_security_columns.js @@ -0,0 +1,25 @@ +exports.up = function(knex) { + return knex.schema.table('admin_users', table => { + // Add password change tracking + table.timestamp('password_changed_at').nullable(); + + // Add last login IP for security monitoring + table.string('last_login_ip', 45).nullable(); + + // Add account security flags + table.boolean('two_factor_enabled').defaultTo(false); + table.string('two_factor_secret').nullable(); + + // Add index for performance + table.index('password_changed_at'); + }); +}; + +exports.down = function(knex) { + return knex.schema.table('admin_users', table => { + table.dropColumn('password_changed_at'); + table.dropColumn('last_login_ip'); + table.dropColumn('two_factor_enabled'); + table.dropColumn('two_factor_secret'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/017_add_token_revocation_tables.js b/backend/migrations/017_add_token_revocation_tables.js new file mode 100644 index 0000000..159ec63 --- /dev/null +++ b/backend/migrations/017_add_token_revocation_tables.js @@ -0,0 +1,34 @@ +exports.up = function(knex) { + return knex.schema + // Table for individual token revocations + .createTable('revoked_tokens', table => { + table.increments('id').primary(); + table.string('token_id').notNullable().unique(); // JWT ID or generated ID + table.integer('user_id').nullable(); // User who owned the token + table.string('token_type', 20); // admin, gallery, etc. + table.timestamp('revoked_at').defaultTo(knex.fn.now()); + table.timestamp('expires_at').notNullable(); // When token would have expired + table.string('reason', 100); // password_change, logout, compromised, etc. + table.text('metadata'); // Additional JSON data + + // Indexes for performance + table.index('token_id'); + table.index('user_id'); + table.index('expires_at'); // For cleanup + }) + // Table for user-level revocations (revoke all tokens before a certain time) + .createTable('user_token_revocations', table => { + table.integer('user_id').primary(); + table.timestamp('revoked_at').notNullable(); + table.string('reason', 100); + + // Index for quick lookups + table.index('revoked_at'); + }); +}; + +exports.down = function(knex) { + return knex.schema + .dropTableIfExists('user_token_revocations') + .dropTableIfExists('revoked_tokens'); +}; \ No newline at end of file diff --git a/backend/migrations/018_add_created_at_to_email_queue.js b/backend/migrations/018_add_created_at_to_email_queue.js new file mode 100644 index 0000000..c870c8b --- /dev/null +++ b/backend/migrations/018_add_created_at_to_email_queue.js @@ -0,0 +1,23 @@ +exports.up = async function(knex) { + // Check if created_at column already exists + const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at'); + + if (!hasCreatedAt) { + await knex.schema.table('email_queue', (table) => { + table.datetime('created_at').defaultTo(knex.fn.now()); + }); + + // Update existing rows to have a created_at value based on scheduled_at + await knex('email_queue') + .whereNull('created_at') + .update({ + created_at: knex.ref('scheduled_at') + }); + } +}; + +exports.down = async function(knex) { + await knex.schema.table('email_queue', (table) => { + table.dropColumn('created_at'); + }); +}; \ No newline at end of file diff --git a/backend/migrations/019_fix_email_templates_columns.js b/backend/migrations/019_fix_email_templates_columns.js new file mode 100644 index 0000000..bee9f9d --- /dev/null +++ b/backend/migrations/019_fix_email_templates_columns.js @@ -0,0 +1,35 @@ +exports.up = async function(knex) { + // Check current column structure + const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en'); + const hasSubject = await knex.schema.hasColumn('email_templates', 'subject'); + + if (hasSubjectEn && !hasSubject) { + // The language migration was applied, need to add back basic columns + await knex.schema.alterTable('email_templates', function(table) { + table.string('subject'); + table.text('body_html'); + table.text('body_text'); + }); + + // Copy English values to the basic columns + await knex('email_templates').update({ + subject: knex.raw('subject_en'), + body_html: knex.raw('body_html_en'), + body_text: knex.raw('body_text_en') + }); + } +}; + +exports.down = async function(knex) { + // Check if we have the basic columns + const hasSubject = await knex.schema.hasColumn('email_templates', 'subject'); + const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en'); + + if (hasSubject && hasSubjectEn) { + await knex.schema.alterTable('email_templates', function(table) { + table.dropColumn('subject'); + table.dropColumn('body_html'); + table.dropColumn('body_text'); + }); + } +}; \ No newline at end of file diff --git a/backend/migrations/020_ensure_default_email_templates.js b/backend/migrations/020_ensure_default_email_templates.js new file mode 100644 index 0000000..e11a0a6 --- /dev/null +++ b/backend/migrations/020_ensure_default_email_templates.js @@ -0,0 +1,91 @@ +exports.up = async function(knex) { + // Check if we have the default email templates + const templates = await knex('email_templates').select('template_key'); + const existingKeys = templates.map(t => t.template_key); + + // Check which columns exist in the table + const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en'); + const hasSubject = await knex.schema.hasColumn('email_templates', 'subject'); + + // Determine which columns to use based on schema + const subjectCol = hasSubjectEn ? 'subject_en' : 'subject'; + const bodyHtmlCol = hasSubjectEn ? 'body_html_en' : 'body_html'; + const bodyTextCol = hasSubjectEn ? 'body_text_en' : 'body_text'; + + const defaultTemplates = [ + { + template_key: 'gallery_created', + [subjectCol]: 'Your Photo Gallery is Ready!', + [bodyHtmlCol]: `

Gallery Created Successfully

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been created successfully!

+

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Gallery Link: {{gallery_link}}
  • +
  • Password: {{gallery_password}}
  • +
  • Expires: {{expiry_date}}
  • +
+

Share this link and password with your guests to allow them to view and download photos.

`, + [bodyTextCol]: 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!', + variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']) + }, + { + template_key: 'expiration_warning', + [subjectCol]: 'Your Photo Gallery Expires Soon', + [bodyHtmlCol]: `

Gallery Expiring Soon

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.

+

After expiration, the gallery will be archived and no longer accessible to guests.

+

Visit Gallery

`, + [bodyTextCol]: 'Gallery Expiring Soon\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" will expire in {{days_remaining}} days.', + variables: JSON.stringify(['host_name', 'event_name', 'days_remaining', 'gallery_link']) + }, + { + template_key: 'gallery_expired', + [subjectCol]: 'Your Photo Gallery Has Expired', + [bodyHtmlCol]: `

Gallery Expired

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has expired and been archived.

+

The photos are safely stored in our archive system. If you need access to the archived photos, please contact support.

`, + [bodyTextCol]: 'Gallery Expired\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has expired and been archived.', + variables: JSON.stringify(['host_name', 'event_name']) + }, + { + template_key: 'archive_complete', + [subjectCol]: 'Gallery Archive Complete', + [bodyHtmlCol]: `

Archive Complete

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully archived.

+

Archive size: {{archive_size}}

+

The archive is stored securely and can be retrieved if needed.

`, + [bodyTextCol]: 'Archive Complete\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been successfully archived.', + variables: JSON.stringify(['host_name', 'event_name', 'archive_size']) + } + ]; + + // Insert missing templates + for (const template of defaultTemplates) { + if (!existingKeys.includes(template.template_key)) { + // If we have language columns, also set German versions with same content + if (hasSubjectEn) { + template.subject_de = template[subjectCol]; + template.body_html_de = template[bodyHtmlCol]; + template.body_text_de = template[bodyTextCol]; + + // Also ensure we have the basic columns if they exist + if (hasSubject) { + template.subject = template[subjectCol]; + template.body_html = template[bodyHtmlCol]; + template.body_text = template[bodyTextCol]; + } + } + + await knex('email_templates').insert(template); + } + } +}; + +exports.down = async function(knex) { + // Don't remove templates on rollback as they might have been customized +}; \ No newline at end of file diff --git a/backend/migrations/021_add_default_cms_pages.js b/backend/migrations/021_add_default_cms_pages.js new file mode 100644 index 0000000..29c17fb --- /dev/null +++ b/backend/migrations/021_add_default_cms_pages.js @@ -0,0 +1,121 @@ +exports.up = async function(knex) { + // Check if CMS pages already exist + const impressumExists = await knex('cms_pages') + .where('slug', 'impressum') + .first(); + + const datenschutzExists = await knex('cms_pages') + .where('slug', 'datenschutz') + .first(); + + const pagesToInsert = []; + + // Add Impressum page if it doesn't exist + if (!impressumExists) { + pagesToInsert.push({ + slug: 'impressum', + title_en: 'Legal Notice', + title_de: 'Impressum', + content_en: `

Legal Notice

+

Information according to ยง 5 TMG

+ +

Responsible for content

+

[Your Name]
+[Your Address]
+[Postal Code City]

+ +

Contact

+

Email: [Your Email Address]
+Phone: [Your Phone Number]

+ +

Disclaimer

+

Liability for content

+

The contents of our pages were created with great care. However, we cannot guarantee the accuracy, completeness and timeliness of the content.

+ +

Liability for links

+

Our website contains links to external third-party websites over whose content we have no influence. Therefore, we cannot accept any liability for this third-party content.

`, + content_de: `

Impressum

+

Angaben gemรครŸ ยง 5 TMG

+ +

Verantwortlich fรผr den Inhalt

+

[Ihr Name]
+[Ihre Adresse]
+[PLZ Ort]

+ +

Kontakt

+

E-Mail: [Ihre E-Mail-Adresse]
+Telefon: [Ihre Telefonnummer]

+ +

Haftungsausschluss

+

Haftung fรผr Inhalte

+

Die Inhalte unserer Seiten wurden mit grรถรŸter Sorgfalt erstellt. Fรผr die Richtigkeit, Vollstรคndigkeit und Aktualitรคt der Inhalte kรถnnen wir jedoch keine Gewรคhr รผbernehmen.

+ +

Haftung fรผr Links

+

Unser Angebot enthรคlt Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb kรถnnen wir fรผr diese fremden Inhalte auch keine Gewรคhr รผbernehmen.

`, + updated_at: new Date() + }); + } + + // Add Datenschutz page if it doesn't exist + if (!datenschutzExists) { + pagesToInsert.push({ + slug: 'datenschutz', + title_en: 'Privacy Policy', + title_de: 'Datenschutzerklรคrung', + content_en: `

Privacy Policy

+ +

1. Privacy at a Glance

+

General Information

+

The following information provides a simple overview of what happens to your personal data when you visit this website.

+ +

Data Collection on This Website

+

Who is responsible for data collection on this website?

+

Data processing on this website is carried out by the website operator. Their contact details can be found in the legal notice of this website.

+ +

How do we collect your data?

+

Your data is collected when you provide it to us. This could be data that you enter into a contact form, for example.

+ +

What do we use your data for?

+

Some of the data is collected to ensure error-free provision of the website. Other data may be used to analyze your user behavior.

+ +

2. Hosting

+

This website is hosted externally. The personal data collected on this website is stored on the servers of the host.

+ +

3. General Information and Mandatory Information

+

Data Protection

+

The operators of these pages take the protection of your personal data very seriously. We treat your personal data confidentially and in accordance with the statutory data protection regulations and this privacy policy.

`, + content_de: `

Datenschutzerklรคrung

+ +

1. Datenschutz auf einen Blick

+

Allgemeine Hinweise

+

Die folgenden Hinweise geben einen einfachen รœberblick darรผber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen.

+ +

Datenerfassung auf dieser Website

+

Wer ist verantwortlich fรผr die Datenerfassung auf dieser Website?

+

Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten kรถnnen Sie dem Impressum dieser Website entnehmen.

+ +

Wie erfassen wir Ihre Daten?

+

Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.

+ +

Wofรผr nutzen wir Ihre Daten?

+

Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewรคhrleisten. Andere Daten kรถnnen zur Analyse Ihres Nutzerverhaltens verwendet werden.

+ +

2. Hosting

+

Diese Website wird extern gehostet. Die personenbezogenen Daten, die auf dieser Website erfasst werden, werden auf den Servern des Hosters gespeichert.

+ +

3. Allgemeine Hinweise und Pflichtinformationen

+

Datenschutz

+

Die Betreiber dieser Seiten nehmen den Schutz Ihrer persรถnlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklรคrung.

`, + updated_at: new Date() + }); + } + + // Insert pages if any need to be added + if (pagesToInsert.length > 0) { + await knex('cms_pages').insert(pagesToInsert); + } +}; + +exports.down = async function(knex) { + // Don't remove CMS pages on rollback as they might have been customized +}; \ No newline at end of file diff --git a/backend/migrations/022_fix_json_columns.js b/backend/migrations/022_fix_json_columns.js new file mode 100644 index 0000000..752b02c --- /dev/null +++ b/backend/migrations/022_fix_json_columns.js @@ -0,0 +1,68 @@ +exports.up = async function(knex) { + console.log('Fixing JSON columns in database...'); + + // Fix email_templates variables column + const templates = await knex('email_templates').select('id', 'template_key', 'variables'); + + for (const template of templates) { + if (template.variables && typeof template.variables === 'string') { + try { + // Check if it's already valid JSON + JSON.parse(template.variables); + } catch (e) { + console.log(`Fixing invalid JSON in email template ${template.template_key}`); + // Attempt to fix common issues + let fixed = template.variables; + + // If it looks like an array but isn't valid JSON, try to fix it + if (fixed.startsWith('[') && fixed.endsWith(']')) { + // Extract the content and properly format it + const content = fixed.slice(1, -1); + const items = content.split(',').map(item => item.trim().replace(/['"]/g, '')); + fixed = JSON.stringify(items); + } else { + // Default to empty array if we can't fix it + fixed = JSON.stringify([]); + } + + await knex('email_templates') + .where('id', template.id) + .update({ variables: fixed }); + } + } else if (!template.variables) { + // Set default empty array for null values + await knex('email_templates') + .where('id', template.id) + .update({ variables: JSON.stringify([]) }); + } + } + + // Fix activity_logs metadata column + const activities = await knex('activity_logs').select('id', 'metadata'); + + for (const activity of activities) { + if (activity.metadata && typeof activity.metadata === 'string') { + try { + // Check if it's already valid JSON + JSON.parse(activity.metadata); + } catch (e) { + console.log(`Fixing invalid JSON in activity log ${activity.id}`); + // Default to empty object if we can't parse it + await knex('activity_logs') + .where('id', activity.id) + .update({ metadata: JSON.stringify({}) }); + } + } else if (!activity.metadata) { + // Set default empty object for null values + await knex('activity_logs') + .where('id', activity.id) + .update({ metadata: JSON.stringify({}) }); + } + } + + console.log('JSON columns fixed successfully'); +}; + +exports.down = async function(knex) { + // No rollback needed - data fixes only +}; \ No newline at end of file diff --git a/backend/migrations/023_ensure_postgres_compatibility.js b/backend/migrations/023_ensure_postgres_compatibility.js new file mode 100644 index 0000000..40e3037 --- /dev/null +++ b/backend/migrations/023_ensure_postgres_compatibility.js @@ -0,0 +1,22 @@ +/** + * Ensure PostgreSQL compatibility for all insert operations + * This migration doesn't change the schema but ensures all tables + * are compatible with .returning() syntax + */ + +exports.up = async function(knex) { + // This migration is informational only + // All insert operations should use .returning('id') going forward + + console.log('PostgreSQL compatibility check:'); + console.log('- All INSERT operations should use .returning("id")'); + console.log('- All date operations should use ISO strings'); + console.log('- Boolean values are handled automatically by Knex'); + + return Promise.resolve(); +}; + +exports.down = async function(knex) { + // No rollback needed + return Promise.resolve(); +}; \ No newline at end of file diff --git a/backend/migrations/024_fix_boolean_compatibility.js b/backend/migrations/024_fix_boolean_compatibility.js new file mode 100644 index 0000000..d31147a --- /dev/null +++ b/backend/migrations/024_fix_boolean_compatibility.js @@ -0,0 +1,29 @@ +/** + * Fix boolean compatibility issues between PostgreSQL and SQLite + * This migration updates the database configuration and existing data + */ + +exports.up = async function(knex) { + const isPostgres = knex.client.config.client === 'pg'; + + if (!isPostgres) { + // Enable foreign keys for SQLite + await knex.raw('PRAGMA foreign_keys = ON'); + + // Note: SQLite stores booleans as 0/1 + // No data migration needed as Knex handles this automatically + // But queries must use formatBoolean() helper + + console.log('SQLite boolean compatibility check:'); + console.log('- SQLite stores booleans as 0/1'); + console.log('- All boolean comparisons should use formatBoolean() helper'); + console.log('- Foreign keys enabled'); + } + + return Promise.resolve(); +}; + +exports.down = async function(knex) { + // No rollback needed + return Promise.resolve(); +}; \ No newline at end of file diff --git a/backend/migrations/025_fix_email_queue_updated_at.js b/backend/migrations/025_fix_email_queue_updated_at.js new file mode 100644 index 0000000..eab064f --- /dev/null +++ b/backend/migrations/025_fix_email_queue_updated_at.js @@ -0,0 +1,33 @@ +/** + * Fix email_queue table by ensuring it doesn't have updated_at column + * This migration addresses the PostgreSQL error where queries are trying to update + * a non-existent updated_at column + */ + +exports.up = async function(knex) { + // First, check if the column exists + const hasUpdatedAt = await knex.schema.hasColumn('email_queue', 'updated_at'); + + if (hasUpdatedAt) { + console.log('Found updated_at column in email_queue table, removing it...'); + await knex.schema.table('email_queue', (table) => { + table.dropColumn('updated_at'); + }); + } + + // Also ensure the table has all required columns + const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at'); + if (!hasCreatedAt) { + console.log('Adding missing created_at column to email_queue table...'); + await knex.schema.table('email_queue', (table) => { + table.datetime('created_at').defaultTo(knex.fn.now()); + }); + } + + console.log('email_queue table schema fixed'); +}; + +exports.down = async function(knex) { + // In the down migration, we don't add back updated_at since it shouldn't exist + // This is intentionally left minimal +}; \ No newline at end of file diff --git a/backend/migrations/026_fix_german_email_templates.js b/backend/migrations/026_fix_german_email_templates.js new file mode 100644 index 0000000..51276a6 --- /dev/null +++ b/backend/migrations/026_fix_german_email_templates.js @@ -0,0 +1,234 @@ +exports.up = async function(knex) { + // Update gallery_created template with proper German translation + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + subject_de: 'Ihre Fotogalerie ist bereit!', + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+{{#if welcome_message}} +
+

Persรถnliche Nachricht:

+

{{welcome_message}}

+
+{{/if}} +

Galerie-Details:

+
    +
  • Veranstaltungsdatum: {{event_date}}
  • +
  • Galerie-Link: {{gallery_link}}
  • +
  • Passwort: {{gallery_password}}
  • +
  • Ablaufdatum: {{expiry_date}}
  • +
+

Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit diese die Fotos ansehen und herunterladen kรถnnen.

+

+ Wichtig: Diese Galerie lรคuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugรคnglich. +

+Galerie anzeigen +

Mit freundlichen GrรผรŸen,
Ihr Foto-Sharing-Team

`, + body_text_de: `Galerie erfolgreich erstellt + +Liebe(r) {{host_name}}, + +Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt! + +{{#if welcome_message}} +Persรถnliche Nachricht: +{{welcome_message}} + +{{/if}} +Galerie-Details: +- Veranstaltungsdatum: {{event_date}} +- Galerie-Link: {{gallery_link}} +- Passwort: {{gallery_password}} +- Ablaufdatum: {{expiry_date}} + +Teilen Sie diesen Link und das Passwort mit Ihren Gรคsten, damit diese die Fotos ansehen und herunterladen kรถnnen. + +WICHTIG: Diese Galerie lรคuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugรคnglich. + +Mit freundlichen GrรผรŸen, +Ihr Foto-Sharing-Team` + }); + + // Update expiration_warning template with proper German translation + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + subject_de: 'Ihre Fotogalerie lรคuft bald ab', + body_html_de: `

Galerie lรคuft bald ab

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" lรคuft in {{days_remaining}} Tagen ab.

+

Nach Ablauf wird die Galerie archiviert und ist fรผr Gรคste nicht mehr zugรคnglich. Bitte stellen Sie sicher, dass alle gewรผnschten Fotos heruntergeladen wurden.

+

Ablaufdatum: {{expiry_date}}

+Galerie jetzt besuchen +

+ Erinnerung: Nach dem {{expiry_date}} kรถnnen Ihre Gรคste nicht mehr auf die Galerie zugreifen. +

+

Mit freundlichen GrรผรŸen,
Ihr Foto-Sharing-Team

`, + body_text_de: `Galerie lรคuft bald ab + +Liebe(r) {{host_name}}, + +Ihre Fotogalerie "{{event_name}}" lรคuft in {{days_remaining}} Tagen ab. + +Nach Ablauf wird die Galerie archiviert und ist fรผr Gรคste nicht mehr zugรคnglich. Bitte stellen Sie sicher, dass alle gewรผnschten Fotos heruntergeladen wurden. + +Ablaufdatum: {{expiry_date}} + +Galerie-Link: {{gallery_link}} + +ERINNERUNG: Nach dem {{expiry_date}} kรถnnen Ihre Gรคste nicht mehr auf die Galerie zugreifen. + +Mit freundlichen GrรผรŸen, +Ihr Foto-Sharing-Team` + }); + + // Update gallery_expired template with proper German translation + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + subject_de: 'Ihre Fotogalerie {{event_name}} ist abgelaufen', + body_html_de: `

Galerie abgelaufen

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.

+

Die Galerie ist nicht mehr fรผr Gรคste zugรคnglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.

+

Wenn Sie wieder Zugriff auf die archivierten Fotos benรถtigen, wenden Sie sich bitte an unseren Support:

+

+ Kontakt:
+ E-Mail: {{admin_email}}
+ {{#if support_phone}}Telefon: {{support_phone}}{{/if}} +

+

Vielen Dank fรผr die Nutzung unseres Foto-Sharing-Services!

+

Mit freundlichen GrรผรŸen,
Ihr Foto-Sharing-Team

`, + body_text_de: `Galerie abgelaufen + +Liebe(r) {{host_name}}, + +Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert. + +Die Galerie ist nicht mehr fรผr Gรคste zugรคnglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert. + +Wenn Sie wieder Zugriff auf die archivierten Fotos benรถtigen, wenden Sie sich bitte an unseren Support: + +E-Mail: {{admin_email}} +{{#if support_phone}}Telefon: {{support_phone}}{{/if}} + +Vielen Dank fรผr die Nutzung unseres Foto-Sharing-Services! + +Mit freundlichen GrรผรŸen, +Ihr Foto-Sharing-Team` + }); + + // Update archive_complete template with proper German translation + await knex('email_templates') + .where('template_key', 'archive_complete') + .update({ + subject_de: 'Archivierung abgeschlossen: {{event_name}}', + body_html_de: `

Archivierung abgeschlossen

+

Liebe(r) {{host_name}},

+

Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.

+

Archiv-Details:

+
    +
  • ArchivgrรถรŸe: {{archive_size}}
  • +
  • Archivierungsdatum: {{archive_date}}
  • +
  • Anzahl der Fotos: {{photo_count}}
  • +
+

Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf kรถnnen Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.

+

+ โœ“ Erfolgreich archiviert: Ihre Fotos sind sicher gespeichert und kรถnnen bei Bedarf wiederhergestellt werden. +

+

Kontakt fรผr Archivzugriff:
+E-Mail: {{admin_email}}

+

Mit freundlichen GrรผรŸen,
Ihr Foto-Sharing-Team

`, + body_text_de: `Archivierung abgeschlossen + +Liebe(r) {{host_name}}, + +Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert. + +Archiv-Details: +- ArchivgrรถรŸe: {{archive_size}} +- Archivierungsdatum: {{archive_date}} +- Anzahl der Fotos: {{photo_count}} + +Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf kรถnnen Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten. + +โœ“ ERFOLGREICH ARCHIVIERT: Ihre Fotos sind sicher gespeichert und kรถnnen bei Bedarf wiederhergestellt werden. + +Kontakt fรผr Archivzugriff: +E-Mail: {{admin_email}} + +Mit freundlichen GrรผรŸen, +Ihr Foto-Sharing-Team` + }); + + // Also update the non-language-specific fields to match German for consistency + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + subject: knex.raw('subject_de'), + body_html: knex.raw('body_html_de'), + body_text: knex.raw('body_text_de') + }); + + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + subject: knex.raw('subject_de'), + body_html: knex.raw('body_html_de'), + body_text: knex.raw('body_text_de') + }); + + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + subject: knex.raw('subject_de'), + body_html: knex.raw('body_html_de'), + body_text: knex.raw('body_text_de') + }); + + await knex('email_templates') + .where('template_key', 'archive_complete') + .update({ + subject: knex.raw('subject_de'), + body_html: knex.raw('body_html_de'), + body_text: knex.raw('body_text_de') + }); +}; + +exports.down = async function(knex) { + // Revert to previous German translations + // This is a simplified rollback - in production you might want to store the old values + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + subject: knex.raw('subject_en'), + body_html: knex.raw('body_html_en'), + body_text: knex.raw('body_text_en') + }); + + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + subject: knex.raw('subject_en'), + body_html: knex.raw('body_html_en'), + body_text: knex.raw('body_text_en') + }); + + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + subject: knex.raw('subject_en'), + body_html: knex.raw('body_html_en'), + body_text: knex.raw('body_text_en') + }); + + await knex('email_templates') + .where('template_key', 'archive_complete') + .update({ + subject: knex.raw('subject_en'), + body_html: knex.raw('body_html_en'), + body_text: knex.raw('body_text_en') + }); +}; \ No newline at end of file diff --git a/backend/migrations/027_add_language_preferences.js b/backend/migrations/027_add_language_preferences.js new file mode 100644 index 0000000..7640e38 --- /dev/null +++ b/backend/migrations/027_add_language_preferences.js @@ -0,0 +1,41 @@ +exports.up = async function(knex) { + // Add language column to events table if it doesn't exist + const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language'); + if (!hasLanguageInEvents) { + await knex.schema.alterTable('events', function(table) { + table.string('language', 5).defaultTo('en'); + }); + } + + // Add default_language to email_configs if it doesn't exist + const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language'); + if (!hasDefaultLanguage) { + await knex.schema.alterTable('email_configs', function(table) { + table.string('default_language', 5).defaultTo('en'); + }); + } + + // Set default language to German for the existing email config + await knex('email_configs') + .update({ + default_language: 'de' + }); +}; + +exports.down = async function(knex) { + // Remove language column from events table + const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language'); + if (hasLanguageInEvents) { + await knex.schema.alterTable('events', function(table) { + table.dropColumn('language'); + }); + } + + // Remove default_language from email_configs + const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language'); + if (hasDefaultLanguage) { + await knex.schema.alterTable('email_configs', function(table) { + table.dropColumn('default_language'); + }); + } +}; \ No newline at end of file diff --git a/backend/migrations/027_add_rate_limit_settings.js b/backend/migrations/027_add_rate_limit_settings.js new file mode 100644 index 0000000..8fa5d9b --- /dev/null +++ b/backend/migrations/027_add_rate_limit_settings.js @@ -0,0 +1,63 @@ +exports.up = async function(knex) { + // Add rate limit settings to app_settings + const rateLimitSettings = [ + { + setting_key: 'rate_limit_enabled', + setting_value: JSON.stringify(true), + setting_type: 'security' + }, + { + setting_key: 'rate_limit_window_minutes', + setting_value: JSON.stringify(15), + setting_type: 'security' + }, + { + setting_key: 'rate_limit_max_requests', + setting_value: JSON.stringify(1000), + setting_type: 'security' + }, + { + setting_key: 'rate_limit_auth_max_requests', + setting_value: JSON.stringify(5), + setting_type: 'security' + }, + { + setting_key: 'rate_limit_skip_authenticated', + setting_value: JSON.stringify(true), + setting_type: 'security' + }, + { + setting_key: 'rate_limit_public_endpoints_only', + setting_value: JSON.stringify(false), + setting_type: 'security' + } + ]; + + // Insert settings if they don't exist + for (const setting of rateLimitSettings) { + const exists = await knex('app_settings') + .where('setting_key', setting.setting_key) + .first(); + + if (!exists) { + await knex('app_settings').insert({ + ...setting, + updated_at: knex.fn.now() + }); + } + } +}; + +exports.down = async function(knex) { + // Remove rate limit settings + await knex('app_settings') + .whereIn('setting_key', [ + 'rate_limit_enabled', + 'rate_limit_window_minutes', + 'rate_limit_max_requests', + 'rate_limit_auth_max_requests', + 'rate_limit_skip_authenticated', + 'rate_limit_public_endpoints_only' + ]) + .del(); +}; \ No newline at end of file diff --git a/backend/migrations/028_update_english_templates_to_match_german.js b/backend/migrations/028_update_english_templates_to_match_german.js new file mode 100644 index 0000000..432824d --- /dev/null +++ b/backend/migrations/028_update_english_templates_to_match_german.js @@ -0,0 +1,307 @@ +exports.up = async function(knex) { + // Update English templates to match the quality and content of German templates + + // 1. Gallery Created - Match German version with proper styling and conditionals + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + subject_en: 'Your photo gallery is ready', + body_html_en: ` +

Hello {{host_name}},

+ +

Your photo gallery {{event_name}} for {{event_date}} has been successfully created and is now online!

+ +{{#if welcome_message}} +
+

Personal message from your photographer:

+

{{welcome_message}}

+
+{{/if}} + +
+

Your access data:

+ +
+ + + +
+

Important: Your gallery will be available until {{expiry_date}}. After this date, the photos will be archived and will only be available upon request.

+
+ +

We hope you enjoy your photos!

+ +

Best regards,
+Your Photo Sharing Team

`, + body_text_en: `Hello {{host_name}}, + +Your photo gallery "{{event_name}}" for {{event_date}} has been successfully created and is now online! + +{{#if welcome_message}} +Personal message from your photographer: +{{welcome_message}} +{{/if}} + +Your access data: +- Gallery link: {{gallery_link}} +- Password: {{gallery_password}} + +Important: Your gallery will be available until {{expiry_date}}. After this date, the photos will be archived and will only be available upon request. + +We hope you enjoy your photos! + +Best regards, +Your Photo Sharing Team` + }); + + // 2. Expiration Warning - Match German version with urgency and styling + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + subject_en: 'Your photo gallery expires soon', + body_html_en: ` +

Hello {{host_name}},

+ +

Your photo gallery {{event_name}} will expire in {{days_remaining}} days!

+ +
+

โš ๏ธ Important Notice

+

After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.

+
+ +

Don't miss out โ€“ download your photos now!

+ + + +
+

Quick reminder of your access data:

+ +
+ +

If you have any questions, please don't hesitate to contact us.

+ +

Best regards,
+Your Photo Sharing Team

`, + body_text_en: `Hello {{host_name}}, + +Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days! + +โš ๏ธ Important Notice +After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request. + +Don't miss out โ€“ download your photos now! + +Quick reminder of your access data: +- Gallery link: {{gallery_link}} +- Password: {{gallery_password}} + +If you have any questions, please don't hesitate to contact us. + +Best regards, +Your Photo Sharing Team` + }); + + // 3. Gallery Expired - Match German version with contact information + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + subject_en: 'Your photo gallery has expired', + body_html_en: ` +

Hello {{host_name}},

+ +

Your photo gallery {{event_name}} expired on {{expiry_date}} and is no longer accessible online.

+ +
+

Your photos are safely archived

+

Don't worry โ€“ your photos have been securely archived and are not lost. If you need access to your photos, please contact us:

+
    +
  • ๐Ÿ“ง Email: {{support_email}}
  • + {{#if support_phone}} +
  • ๐Ÿ“ž Phone: {{support_phone}}
  • + {{/if}} +
+
+ +

Please have the following information ready when contacting us:

+
    +
  • Event name: {{event_name}}
  • +
  • Event date: {{event_date}}
  • +
  • Expiry date: {{expiry_date}}
  • +
+ +

We'll be happy to help you access your archived photos.

+ +

Best regards,
+Your Photo Sharing Team

`, + body_text_en: `Hello {{host_name}}, + +Your photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online. + +Your photos are safely archived +Don't worry โ€“ your photos have been securely archived and are not lost. If you need access to your photos, please contact us: + +๐Ÿ“ง Email: {{support_email}} +{{#if support_phone}}๐Ÿ“ž Phone: {{support_phone}}{{/if}} + +Please have the following information ready when contacting us: +- Event name: {{event_name}} +- Event date: {{event_date}} +- Expiry date: {{expiry_date}} + +We'll be happy to help you access your archived photos. + +Best regards, +Your Photo Sharing Team` + }); + + // 4. Archive Complete - Match German version with success message and details + await knex('email_templates') + .where('template_key', 'archive_complete') + .update({ + subject_en: 'Your photo gallery has been successfully archived', + body_html_en: ` +

Hello {{host_name}},

+ +

Your photo gallery {{event_name}} has been successfully archived.

+ +
+

โœ… Archive successfully created

+

Your photos are now safely stored in our archive.

+
+ +
+

Archive details:

+
    +
  • Event: {{event_name}}
  • +
  • Archive date: {{archive_date}}
  • +
  • Number of photos: {{photo_count}}
  • +
  • Archive size: {{archive_size}}
  • +
+
+ +

If you need access to your archived photos in the future, please contact us at:

+

+ ๐Ÿ“ง {{support_email}}
+ {{#if support_phone}}๐Ÿ“ž {{support_phone}}{{/if}} +

+ +

Thank you for using our photo sharing service!

+ +

Best regards,
+Your Photo Sharing Team

`, + body_text_en: `Hello {{host_name}}, + +Your photo gallery "{{event_name}}" has been successfully archived. + +โœ… Archive successfully created +Your photos are now safely stored in our archive. + +Archive details: +- Event: {{event_name}} +- Archive date: {{archive_date}} +- Number of photos: {{photo_count}} +- Archive size: {{archive_size}} + +If you need access to your archived photos in the future, please contact us at: +๐Ÿ“ง {{support_email}} +{{#if support_phone}}๐Ÿ“ž {{support_phone}}{{/if}} + +Thank you for using our photo sharing service! + +Best regards, +Your Photo Sharing Team` + }); + + // 5. Test Email - Update to match German style + await knex('email_templates') + .where('template_key', 'test_email') + .update({ + subject_en: 'Test Email - Photo Sharing Platform', + body_html_en: ` +

Test Email

+ +

This is a test email from your photo sharing platform.

+ +
+

โœ… Email configuration successful!

+

Your email settings have been configured correctly and emails can be sent.

+
+ +
+

Configuration details:

+
    +
  • Timestamp: {{timestamp}}
  • +
  • Sender: {{from_email}}
  • +
+
+ +

Best regards,
+Your Photo Sharing Team

`, + body_text_en: `Test Email + +This is a test email from your photo sharing platform. + +โœ… Email configuration successful! +Your email settings have been configured correctly and emails can be sent. + +Configuration details: +- Timestamp: {{timestamp}} +- Sender: {{from_email}} + +Best regards, +Your Photo Sharing Team` + }); +}; + +exports.down = async function(knex) { + // Revert to previous simpler English templates + await knex('email_templates') + .where('template_key', 'gallery_created') + .update({ + subject_en: 'Your Photo Gallery is Ready', + body_html_en: '

Hello,

Your photo gallery "{{event_name}}" has been created.

Access Link: {{gallery_link}}

Password: {{gallery_password}}

The gallery will be available until {{expiry_date}}.

', + body_text_en: 'Your photo gallery "{{event_name}}" has been created. Access Link: {{gallery_link}} Password: {{gallery_password}} The gallery will be available until {{expiry_date}}.' + }); + + await knex('email_templates') + .where('template_key', 'expiration_warning') + .update({ + subject_en: 'Gallery Expires in {{days_remaining}} Days', + body_html_en: '

Reminder

Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.

Please download your photos before {{expiry_date}}.

Access Gallery

', + body_text_en: 'Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days. Please download your photos before {{expiry_date}}. Access Gallery: {{gallery_link}}' + }); + + await knex('email_templates') + .where('template_key', 'gallery_expired') + .update({ + subject_en: 'Gallery Expired', + body_html_en: '

Gallery Expired

Your photo gallery "{{event_name}}" has expired and is no longer accessible.

If you need access to your photos, please contact support.

', + body_text_en: 'Your photo gallery "{{event_name}}" has expired and is no longer accessible. If you need access to your photos, please contact support.' + }); + + await knex('email_templates') + .where('template_key', 'archive_complete') + .update({ + subject_en: 'Gallery Archived', + body_html_en: '

Archive Complete

Your gallery "{{event_name}}" has been archived.

Archive size: {{archive_size}}

', + body_text_en: 'Your gallery "{{event_name}}" has been archived. Archive size: {{archive_size}}' + }); + + await knex('email_templates') + .where('template_key', 'test_email') + .update({ + subject_en: 'Test Email', + body_html_en: '

This is a test email sent at {{timestamp}}.

', + body_text_en: 'This is a test email sent at {{timestamp}}.' + }); +}; \ No newline at end of file diff --git a/backend/migrations/helpers.js b/backend/migrations/helpers.js new file mode 100644 index 0000000..a2227cd --- /dev/null +++ b/backend/migrations/helpers.js @@ -0,0 +1,83 @@ +/** + * Migration helper functions for production-safe migrations + */ + +/** + * Create a table only if it doesn't already exist + */ +async function createTableIfNotExists(knex, tableName, callback) { + const exists = await knex.schema.hasTable(tableName); + if (!exists) { + console.log(`Creating table: ${tableName}`); + return knex.schema.createTable(tableName, callback); + } else { + console.log(`Table ${tableName} already exists, skipping...`); + } +} + +/** + * Add column to table only if it doesn't exist + */ +async function addColumnIfNotExists(knex, tableName, columnName, callback) { + const hasColumn = await knex.schema.hasColumn(tableName, columnName); + if (!hasColumn) { + console.log(`Adding column ${columnName} to table ${tableName}`); + return knex.schema.alterTable(tableName, (table) => { + callback(table); + }); + } else { + console.log(`Column ${columnName} already exists in table ${tableName}, skipping...`); + } +} + +/** + * Insert data only if it doesn't already exist + */ +async function insertIfNotExists(knex, tableName, data, uniqueField) { + const exists = await knex(tableName) + .where(uniqueField, data[uniqueField]) + .first(); + + if (!exists) { + console.log(`Inserting ${uniqueField}: ${data[uniqueField]} into ${tableName}`); + return knex(tableName).insert(data); + } else { + console.log(`${uniqueField}: ${data[uniqueField]} already exists in ${tableName}, skipping...`); + } +} + +/** + * Create index only if it doesn't exist + */ +async function createIndexIfNotExists(knex, tableName, columns, indexName) { + // This is database-specific, works for PostgreSQL + if (knex.client.config.client === 'pg') { + const result = await knex.raw(` + SELECT 1 FROM pg_indexes + WHERE tablename = ? AND indexname = ? + `, [tableName, indexName]); + + if (result.rows.length === 0) { + console.log(`Creating index ${indexName} on ${tableName}`); + return knex.schema.alterTable(tableName, (table) => { + table.index(columns, indexName); + }); + } + } else { + // For SQLite, just try to create and ignore errors + try { + await knex.schema.alterTable(tableName, (table) => { + table.index(columns, indexName); + }); + } catch (error) { + // Index probably already exists + } + } +} + +module.exports = { + createTableIfNotExists, + addColumnIfNotExists, + insertIfNotExists, + createIndexIfNotExists +}; \ No newline at end of file diff --git a/backend/migrations/init.js b/backend/migrations/init.js new file mode 100644 index 0000000..162bc28 --- /dev/null +++ b/backend/migrations/init.js @@ -0,0 +1,126 @@ +const bcrypt = require('bcrypt'); +const { db, initializeDatabase } = require('../src/database/db'); +const { generateReadablePassword } = require('../src/utils/passwordGenerator'); +const fs = require('fs').promises; +const path = require('path'); + +async function runMigrations() { + console.log('Running database migrations...'); + + try { + // Initialize tables + await initializeDatabase(); + + // Create default admin user if none exists + const adminExists = await db('admin_users').first(); + if (!adminExists) { + // Generate a secure random password + const generatedPassword = generateReadablePassword(); + const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security + + await db('admin_users').insert({ + username: 'admin', + email: 'admin@example.com', + password_hash: passwordHash, + must_change_password: true, // Flag for forcing password change + created_at: new Date() + }); + + // Save the generated password to a file for the user to retrieve + const setupInfoPath = path.join(__dirname, '..', '..', 'ADMIN_CREDENTIALS.txt'); + const setupInfo = ` +======================================== +PicPeak Admin Credentials +======================================== + +Your admin account has been created with these credentials: + +Username: admin +Password: ${generatedPassword} + +IMPORTANT SECURITY NOTES: +1. You MUST change this password on first login +2. This file will be created only once +3. Store these credentials securely +4. Delete this file after noting the password + +Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin + +Generated on: ${new Date().toISOString()} +======================================== +`; + + await fs.writeFile(setupInfoPath, setupInfo, 'utf8'); + + console.log('\n========================================'); + console.log('โœ… Admin user created successfully!'); + console.log('========================================'); + console.log('Username: admin'); + console.log(`Password: ${generatedPassword}`); + console.log('\nโš ๏ธ IMPORTANT:'); + console.log('1. Save these credentials securely'); + console.log('2. You will be required to change the password on first login'); + console.log('3. Credentials are also saved in: ADMIN_CREDENTIALS.txt'); + console.log('========================================\n'); + } + + // Create default email templates if none exist + const templateExists = await db('email_templates').first(); + if (!templateExists) { + await db('email_templates').insert([ + { + template_key: 'gallery_created', + subject: 'Your Photo Gallery is Ready!', + body_html: `

Gallery Created Successfully

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been created successfully!

+

Gallery Details:

+
    +
  • Event Date: {{event_date}}
  • +
  • Gallery Link: {{gallery_link}}
  • +
  • Password: {{gallery_password}}
  • +
  • Expires: {{expiry_date}}
  • +
+

Share this link and password with your guests to allow them to view and download photos.

`, + body_text: 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!', + variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']) + }, + { + template_key: 'expiration_warning', + subject: 'Your Photo Gallery Expires Soon', + body_html: `

Gallery Expiring Soon

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.

+

After expiration, the gallery will be archived and no longer accessible to guests.

+

Visit Gallery

`, + body_text: 'Gallery Expiring Soon\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" will expire in {{days_remaining}} days.', + variables: JSON.stringify(['host_name', 'event_name', 'days_remaining', 'gallery_link']) + } + ]); + console.log('Default email templates created'); + } + + // Create default email config if none exists + const emailConfig = await db('email_configs').first(); + if (!emailConfig) { + await db('email_configs').insert({ + smtp_host: process.env.SMTP_HOST || 'mailhog', + smtp_port: process.env.SMTP_PORT || 1025, + smtp_secure: process.env.SMTP_SECURE === 'true', + smtp_user: process.env.SMTP_USER || '', + smtp_pass: process.env.SMTP_PASS || '', + from_email: process.env.EMAIL_FROM || 'noreply@photo-sharing.local', + from_name: 'Photo Sharing' + }); + console.log('Default email configuration created'); + } + + console.log('Migrations completed successfully'); + process.exit(0); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } +} + +runMigrations(); diff --git a/backend/migrations/run-migrations-safe.js b/backend/migrations/run-migrations-safe.js new file mode 100644 index 0000000..cda8bba --- /dev/null +++ b/backend/migrations/run-migrations-safe.js @@ -0,0 +1,170 @@ +const fs = require('fs').promises; +const path = require('path'); +const { db } = require('../src/database/db'); + +/** + * Production-safe migration runner that handles existing schema + */ + +// Create or verify migrations tracking table +async function ensureMigrationsTable() { + const tableExists = await db.schema.hasTable('migrations'); + if (!tableExists) { + await db.schema.createTable('migrations', (table) => { + table.increments('id').primary(); + table.string('filename').unique().notNullable(); + table.timestamp('applied_at').defaultTo(db.fn.now()); + }); + console.log('Created migrations tracking table'); + } +} + +// Check if a migration has been applied +async function isMigrationApplied(filename) { + const result = await db('migrations').where('filename', filename).first(); + return !!result; +} + +// Mark migration as applied without running it (for existing schema) +async function markMigrationAsApplied(filename) { + await db('migrations').insert({ filename }); + console.log(`Marked migration ${filename} as applied`); +} + +// Detect existing schema and mark migrations as applied +async function detectExistingSchema() { + console.log('Detecting existing schema...'); + + const tableChecks = [ + { table: 'events', migration: 'init.js' }, + { table: 'photos', migration: 'init.js' }, + { table: 'photo_categories', migration: '004_add_categories_and_cms.js' }, + { table: 'cms_pages', migration: '004_add_categories_and_cms.js' }, + { table: 'login_attempts', migration: '015_add_login_attempts_table.js' }, + { table: 'token_blacklist', migration: '017_add_token_revocation_tables.js' }, + ]; + + for (const check of tableChecks) { + const exists = await db.schema.hasTable(check.table); + if (exists) { + const isApplied = await isMigrationApplied(check.migration); + if (!isApplied) { + await markMigrationAsApplied(check.migration); + } + } + } +} + +// Run a single migration safely +async function runMigrationSafely(filename) { + try { + const migrationPath = path.join(__dirname, filename); + const migration = require(migrationPath); + + if (migration.up) { + console.log(`Running migration: ${filename}`); + + // Run migration in a transaction if possible + if (db.client.config.client === 'pg') { + await db.transaction(async (trx) => { + await migration.up(trx); + }); + } else { + await migration.up(db); + } + + await db('migrations').insert({ filename }); + console.log(`Migration ${filename} completed successfully`); + } + } catch (error) { + // Check if error is because schema already exists + if (error.code === '42P07' || // PostgreSQL: relation already exists + error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) { + console.log(`Migration ${filename} - schema already exists, marking as applied`); + await markMigrationAsApplied(filename); + } else { + throw error; + } + } +} + +// Main migration runner +async function runMigrations() { + let connection; + try { + console.log('Starting production-safe database migrations...'); + + // Ensure database connection is ready + await db.raw('SELECT 1'); + console.log('Database connection verified'); + + // Create migrations tracking table + await ensureMigrationsTable(); + + // Detect and mark existing schema + await detectExistingSchema(); + + // Get all migration files + const files = await fs.readdir(__dirname); + const migrationFiles = files + .filter(f => f.match(/^\d{3}_.*\.js$/) || f === 'init.js') + .sort((a, b) => { + // Ensure init.js runs first + if (a === 'init.js') return -1; + if (b === 'init.js') return 1; + return a.localeCompare(b); + }); + + // Run pending migrations + let pendingCount = 0; + let skippedCount = 0; + + for (const file of migrationFiles) { + const isApplied = await isMigrationApplied(file); + if (!isApplied) { + await runMigrationSafely(file); + pendingCount++; + } else { + skippedCount++; + } + } + + console.log(`\nMigration Summary:`); + console.log(`- Applied: ${pendingCount} migration(s)`); + console.log(`- Skipped: ${skippedCount} migration(s) (already applied)`); + console.log(`- Total: ${migrationFiles.length} migration(s)`); + console.log('\nAll migrations completed successfully'); + + // Close database connection + await db.destroy(); + process.exit(0); + } catch (error) { + console.error('\nโŒ Migration failed:', error.message); + console.error('Error details:', error); + + // Close database connection on error + try { + await db.destroy(); + } catch (e) { + // Ignore + } + + process.exit(1); + } +} + +// Add delay for database readiness in production +async function waitAndRun() { + if (process.env.NODE_ENV === 'production') { + console.log('Waiting 2 seconds for database readiness...'); + await new Promise(resolve => setTimeout(resolve, 2000)); + } + await runMigrations(); +} + +// Only run if called directly +if (require.main === module) { + waitAndRun(); +} + +module.exports = { runMigrations }; \ No newline at end of file diff --git a/backend/migrations/run-migrations.js b/backend/migrations/run-migrations.js new file mode 100644 index 0000000..b9f965c --- /dev/null +++ b/backend/migrations/run-migrations.js @@ -0,0 +1,90 @@ +const fs = require('fs').promises; +const path = require('path'); +const { db } = require('../src/database/db'); + +// Create migrations table if it doesn't exist +async function createMigrationsTable() { + const tableExists = await db.schema.hasTable('migrations'); + if (!tableExists) { + await db.schema.createTable('migrations', (table) => { + table.increments('id').primary(); + table.string('filename').unique().notNullable(); + table.timestamp('applied_at').defaultTo(db.fn.now()); + }); + console.log('Created migrations table'); + } +} + +// Get list of applied migrations +async function getAppliedMigrations() { + const migrations = await db('migrations').select('filename'); + return migrations.map(m => m.filename); +} + +// Run a single migration +async function runMigration(filename) { + const migrationPath = path.join(__dirname, filename); + const migration = require(migrationPath); + + if (migration.up) { + console.log(`Running migration: ${filename}`); + await migration.up(db); + await db('migrations').insert({ filename }); + console.log(`Migration ${filename} completed`); + } +} + +// Main migration runner +async function runMigrations() { + try { + console.log('Starting database migrations...'); + + // First run the init.js if it exists but only if migrations table doesn't exist + const tableExists = await db.schema.hasTable('migrations'); + if (!tableExists) { + const { initializeDatabase } = require('../src/database/db'); + console.log('Running initial database setup...'); + await initializeDatabase(); + } + + // Create migrations table + await createMigrationsTable(); + + // Get all migration files + const files = await fs.readdir(__dirname); + const migrationFiles = files + .filter(f => f.match(/^\d{3}_.*\.js$/)) + .sort(); + + // Get applied migrations + const appliedMigrations = await getAppliedMigrations(); + + // Run pending migrations + let pendingCount = 0; + for (const file of migrationFiles) { + if (!appliedMigrations.includes(file)) { + await runMigration(file); + pendingCount++; + } + } + + if (pendingCount === 0) { + console.log('No pending migrations'); + } else { + console.log(`Applied ${pendingCount} migration(s)`); + } + + console.log('All migrations completed successfully'); + process.exit(0); + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } +} + +// Only run if called directly +if (require.main === module) { + runMigrations(); +} + +module.exports = { runMigrations }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..b7c45eb --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,8609 @@ +{ + "name": "picpeak-backend", + "version": "1.0.64", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "picpeak-backend", + "version": "1.0.64", + "dependencies": { + "adm-zip": "^0.5.16", + "archiver": "^5.3.1", + "axios": "^1.10.0", + "bcrypt": "^5.1.0", + "chokidar": "^3.5.3", + "cors": "^2.8.5", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "express-rate-limit": "^6.7.0", + "express-validator": "^7.0.1", + "form-data": "^4.0.3", + "handlebars": "^4.7.8", + "helmet": "^7.0.0", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", + "joi": "^17.9.1", + "jsonwebtoken": "^9.0.0", + "knex": "^2.4.2", + "multer": "^2.0.1", + "node-cron": "^3.0.2", + "nodemailer": "^6.9.1", + "pg": "^8.16.3", + "react-i18next": "^15.6.0", + "sharp": "^0.32.0", + "sqlite3": "^5.1.6", + "uuid": "^11.1.0", + "winston": "^3.8.2", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "eslint": "^8.40.0", + "jest": "^29.5.0", + "nodemon": "^3.1.10", + "supertest": "^6.3.3" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "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/core/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/@babel/generator": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.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.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@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-compilation-targets/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/@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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "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.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "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-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "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-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "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.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.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/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "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.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "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/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "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/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/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/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", + "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "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/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "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": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "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/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "optional": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "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/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/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "license": "MIT" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/colorspace/node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/colorspace/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/colorspace/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "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": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "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": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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/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/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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/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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-6.11.2.tgz", + "integrity": "sha512-a7uwwfNTh1U60ssiIkuLFWHt4hAC5yxlLGU2VP0X4YNlyEDZAqF4tK3GD3NSitVBrCQmQ0++0uOyFOgC2y4DDw==", + "license": "MIT", + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "express": "^4 || ^5" + } + }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "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-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "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/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "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": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "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/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "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-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", + "license": "MIT" + }, + "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": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "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/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/i18next": { + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", + "integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==", + "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.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "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/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "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==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "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/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "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-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT", + "optional": true + }, + "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-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "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/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "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/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/knex": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/knex/-/knex-2.5.1.tgz", + "integrity": "sha512-z78DgGKUr4SE/6cm7ku+jHvFT0X97aERh/f0MUKAKgFnwCYBEW4TFBqtHWFYiJFid7fMrtpZ/gxJthvz5mEByA==", + "license": "MIT", + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.1", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=12" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/knex/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/knex/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "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.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "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/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "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/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC", + "optional": true + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "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/multer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.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/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.75.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", + "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-gyp/node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", + "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "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/prebuild-install/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/prebuild-install/node_modules/tar-fs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", + "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "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/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "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/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-i18next": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", + "integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "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/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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": { + "queue-microtask": "^1.2.2" + } + }, + "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/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz", + "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/sqlite3/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.22.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", + "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "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-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", + "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-fs/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "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/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "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==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.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/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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/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/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "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/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..1619285 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,52 @@ +{ + "name": "picpeak-backend", + "version": "1.0.64", + "description": "Backend for PicPeak event photo sharing platform", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "migrate": "node migrations/run-migrations.js", + "migrate:safe": "node migrations/run-migrations-safe.js", + "fix-temp-photos": "node scripts/fix-temp-photos.js", + "test": "jest", + "lint": "eslint src/" + }, + "dependencies": { + "adm-zip": "^0.5.16", + "archiver": "^5.3.1", + "axios": "^1.10.0", + "bcrypt": "^5.1.0", + "chokidar": "^3.5.3", + "cors": "^2.8.5", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "express-rate-limit": "^6.7.0", + "express-validator": "^7.0.1", + "form-data": "^4.0.3", + "handlebars": "^4.7.8", + "helmet": "^7.0.0", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", + "joi": "^17.9.1", + "jsonwebtoken": "^9.0.0", + "knex": "^2.4.2", + "multer": "^2.0.1", + "node-cron": "^3.0.2", + "nodemailer": "^6.9.1", + "pg": "^8.16.3", + "react-i18next": "^15.6.0", + "sharp": "^0.32.0", + "sqlite3": "^5.1.6", + "uuid": "^11.1.0", + "winston": "^3.8.2", + "zxcvbn": "^4.4.2" + }, + "devDependencies": { + "eslint": "^8.40.0", + "jest": "^29.5.0", + "nodemon": "^3.1.10", + "supertest": "^6.3.3" + } +} diff --git a/backend/scripts/check-db-issues.js b/backend/scripts/check-db-issues.js new file mode 100644 index 0000000..a2b6fec --- /dev/null +++ b/backend/scripts/check-db-issues.js @@ -0,0 +1,60 @@ +require('dotenv').config(); +const { db } = require('../src/database/db'); + +async function checkDatabaseIssues() { + console.log('Checking database issues...\n'); + + try { + // Check email_templates table structure + console.log('1. Checking email_templates table structure:'); + const emailTemplateColumns = await db('email_templates').columnInfo(); + console.log('Columns:', Object.keys(emailTemplateColumns)); + + // Check if any templates exist + const templateCount = await db('email_templates').count('* as count'); + console.log('Template count:', templateCount[0].count); + + // Check for specific template + const galleryCreatedTemplate = await db('email_templates') + .where('template_key', 'gallery_created') + .first(); + console.log('gallery_created template exists:', !!galleryCreatedTemplate); + + // Check activity_logs table + console.log('\n2. Checking activity_logs table:'); + const activityLogColumns = await db('activity_logs').columnInfo(); + console.log('Columns:', Object.keys(activityLogColumns)); + + // Check migrations table + console.log('\n3. Checking migrations status:'); + const migrations = await db('migrations') + .orderBy('id', 'desc') + .limit(10); + console.log('Latest migrations:'); + migrations.forEach(m => console.log(` - ${m.filename}`)); + + // Test a simple query from notifications route + console.log('\n4. Testing notifications query:'); + try { + const notifications = await db('activity_logs') + .select( + 'activity_logs.*', + 'events.event_name' + ) + .leftJoin('events', 'activity_logs.event_id', 'events.id') + .orderBy('activity_logs.created_at', 'desc') + .limit(5); + console.log(`Found ${notifications.length} notifications`); + } catch (error) { + console.error('Notifications query failed:', error.message); + } + + } catch (error) { + console.error('Error:', error); + } finally { + await db.destroy(); + process.exit(0); + } +} + +checkDatabaseIssues(); \ No newline at end of file diff --git a/backend/scripts/check-db-schema.js b/backend/scripts/check-db-schema.js new file mode 100755 index 0000000..f508832 --- /dev/null +++ b/backend/scripts/check-db-schema.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +const sqlite3 = require('sqlite3').verbose(); + +// Connect to the database +const dbPath = '/app/data/photo_sharing.db'; +console.log(`Connecting to database at: ${dbPath}`); + +const db = new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY, (err) => { + if (err) { + console.error('Error opening database:', err.message); + process.exit(1); + } + console.log('Connected to the SQLite database.\n'); +}); + +// Get schema for events table +console.log('=== EVENTS TABLE SCHEMA ==='); +db.all("PRAGMA table_info(events)", [], (err, rows) => { + if (err) { + console.error('Error getting events schema:', err.message); + } else { + rows.forEach(row => { + console.log(`${row.name} (${row.type})`); + }); + } + + console.log('\n=== PHOTOS TABLE SCHEMA ==='); + // Get schema for photos table + db.all("PRAGMA table_info(photos)", [], (err, rows) => { + if (err) { + console.error('Error getting photos schema:', err.message); + } else { + rows.forEach(row => { + console.log(`${row.name} (${row.type})`); + }); + } + + // Close the database + db.close(); + }); +}); \ No newline at end of file diff --git a/backend/scripts/check-email-environment.js b/backend/scripts/check-email-environment.js new file mode 100644 index 0000000..bfac0f0 --- /dev/null +++ b/backend/scripts/check-email-environment.js @@ -0,0 +1,131 @@ +const { db } = require('../src/database/db'); + +async function checkEmailEnvironment() { + console.log('=== Email Environment Check ===\n'); + + // 1. Check environment variables + console.log('1. Environment Variables:'); + const envVars = [ + 'SMTP_HOST', + 'SMTP_PORT', + 'SMTP_USER', + 'SMTP_PASS', + 'SMTP_FROM', + 'SMTP_SECURE', + 'EMAIL_PROCESSOR_ENABLED', + 'NODE_ENV' + ]; + + envVars.forEach(varName => { + const value = process.env[varName]; + if (varName.includes('PASS')) { + console.log(` ${varName}: ${value ? '***' : 'NOT SET'}`); + } else { + console.log(` ${varName}: ${value || 'NOT SET'}`); + } + }); + + // 2. Check database configuration + console.log('\n2. Database Email Configuration:'); + try { + const emailConfig = await db('email_configs').first(); + if (emailConfig) { + console.log(' Email configuration found in database:'); + console.log(` - SMTP Host: ${emailConfig.smtp_host}`); + console.log(` - SMTP Port: ${emailConfig.smtp_port}`); + console.log(` - SMTP User: ${emailConfig.smtp_user || 'NOT SET'}`); + console.log(` - SMTP Secure: ${emailConfig.smtp_secure}`); + console.log(` - From Address: ${emailConfig.smtp_from}`); + } else { + console.log(' โš ๏ธ No email configuration found in database!'); + console.log(' This will prevent the email processor from initializing.'); + } + } catch (error) { + console.log(` โŒ Error reading email configuration: ${error.message}`); + } + + // 3. Check if the email processor should be disabled + console.log('\n3. Email Processor Status:'); + const isDisabled = process.env.EMAIL_PROCESSOR_ENABLED === 'false'; + if (isDisabled) { + console.log(' โš ๏ธ Email processor is DISABLED via EMAIL_PROCESSOR_ENABLED=false'); + } else { + console.log(' โœ… Email processor is enabled (default)'); + } + + // 4. Check pending emails + console.log('\n4. Email Queue Status:'); + try { + const pending = await db('email_queue') + .where('status', 'pending') + .count('* as count') + .first(); + + const failed = await db('email_queue') + .where('status', 'failed') + .where('retry_count', '>=', 3) + .count('* as count') + .first(); + + const sent = await db('email_queue') + .where('status', 'sent') + .count('* as count') + .first(); + + console.log(` - Pending emails: ${pending.count}`); + console.log(` - Failed emails (max retries): ${failed.count}`); + console.log(` - Sent emails: ${sent.count}`); + } catch (error) { + console.log(` โŒ Error querying email queue: ${error.message}`); + } + + // 5. Test database connection + console.log('\n5. Database Connection:'); + try { + await db.raw('SELECT 1'); + console.log(' โœ… Database connection successful'); + } catch (error) { + console.log(` โŒ Database connection failed: ${error.message}`); + } + + // 6. Check for any recent errors + console.log('\n6. Recent Email Errors:'); + try { + const recentErrors = await db('email_queue') + .whereNotNull('error_message') + .orderBy('id', 'desc') + .limit(3) + .select('id', 'email_type', 'error_message', 'retry_count'); + + if (recentErrors.length > 0) { + recentErrors.forEach((email, index) => { + console.log(` ${index + 1}. Email ID ${email.id} (${email.email_type}):`); + console.log(` Retries: ${email.retry_count}`); + console.log(` Error: ${email.error_message}`); + }); + } else { + console.log(' No recent errors found'); + } + } catch (error) { + console.log(` โŒ Error querying recent errors: ${error.message}`); + } + + console.log('\n=== Environment check complete ==='); + console.log('\nRecommendations:'); + + const emailConfig = await db('email_configs').first().catch(() => null); + if (!emailConfig) { + console.log('โ— Configure email settings in the admin panel or add email_configs record'); + } + + if (!process.env.SMTP_HOST && !emailConfig) { + console.log('โ— Set SMTP environment variables or configure in database'); + } + + await db.destroy(); +} + +checkEmailEnvironment().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/scripts/check-email-processor.js b/backend/scripts/check-email-processor.js new file mode 100644 index 0000000..0fe47ed --- /dev/null +++ b/backend/scripts/check-email-processor.js @@ -0,0 +1,157 @@ +const { db } = require('../src/database/db'); +const winston = require('winston'); + +// Create a simple console logger +const logger = winston.createLogger({ + format: winston.format.simple(), + transports: [new winston.transports.Console()] +}); + +async function checkEmailProcessor() { + try { + logger.info('=== Email Processor Diagnostic Check ===\n'); + + // 1. Check pending emails + logger.info('1. Checking pending emails in queue...'); + const pendingEmails = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .orderBy('created_at', 'asc'); + + logger.info(`Found ${pendingEmails.length} pending emails\n`); + + if (pendingEmails.length > 0) { + logger.info('Pending email details:'); + pendingEmails.forEach((email, index) => { + logger.info(`\nEmail ${index + 1}:`); + logger.info(` ID: ${email.id}`); + logger.info(` Type: ${email.email_type}`); + logger.info(` Recipient: ${email.recipient_email}`); + logger.info(` Event ID: ${email.event_id}`); + logger.info(` Status: ${email.status}`); + logger.info(` Retry Count: ${email.retry_count}`); + logger.info(` Scheduled At: ${email.scheduled_at}`); + logger.info(` Created At: ${email.created_at}`); + logger.info(` Error: ${email.error_message || 'None'}`); + + // Check if email_data needs parsing + logger.info(` Email Data Type: ${typeof email.email_data}`); + if (email.email_data) { + try { + const data = typeof email.email_data === 'string' + ? JSON.parse(email.email_data) + : email.email_data; + logger.info(` Email Data Keys: ${Object.keys(data).join(', ')}`); + } catch (e) { + logger.error(` Failed to parse email_data: ${e.message}`); + } + } + }); + } + + // 2. Check failed emails + logger.info('\n\n2. Checking failed emails...'); + const failedEmails = await db('email_queue') + .where('status', 'failed') + .orderBy('created_at', 'desc') + .limit(5); + + logger.info(`Found ${failedEmails.length} failed emails (showing last 5)\n`); + + if (failedEmails.length > 0) { + failedEmails.forEach((email, index) => { + logger.info(`\nFailed Email ${index + 1}:`); + logger.info(` ID: ${email.id}`); + logger.info(` Type: ${email.email_type}`); + logger.info(` Retry Count: ${email.retry_count}`); + logger.info(` Error: ${email.error_message || 'No error message'}`); + logger.info(` Last Attempt: ${email.sent_at || 'Never'}`); + }); + } + + // 3. Check if email processor should be running + logger.info('\n\n3. Checking email processor configuration...'); + + // Check environment variables + const emailConfig = { + SMTP_HOST: process.env.SMTP_HOST, + SMTP_PORT: process.env.SMTP_PORT, + SMTP_USER: process.env.SMTP_USER, + SMTP_FROM: process.env.SMTP_FROM, + SMTP_SECURE: process.env.SMTP_SECURE, + EMAIL_PROCESSOR_ENABLED: process.env.EMAIL_PROCESSOR_ENABLED || 'true' + }; + + logger.info('Email configuration:'); + Object.entries(emailConfig).forEach(([key, value]) => { + if (key === 'SMTP_USER') { + logger.info(` ${key}: ${value ? '***' : 'NOT SET'}`); + } else { + logger.info(` ${key}: ${value || 'NOT SET'}`); + } + }); + + // 4. Test email processor functionality + logger.info('\n\n4. Testing email processor functionality...'); + + // Import the email processor + const { processEmailQueue, testEmailConnection } = require('../src/services/emailProcessor'); + + // Test email connection + logger.info('Testing email connection...'); + try { + const connectionTest = await testEmailConnection(); + logger.info(`Email connection test: ${connectionTest ? 'SUCCESS' : 'FAILED'}`); + } catch (error) { + logger.error(`Email connection test failed: ${error.message}`); + } + + // Try to process queue once manually + if (pendingEmails.length > 0) { + logger.info('\n\n5. Attempting to process email queue manually...'); + try { + await processEmailQueue(); + logger.info('Manual queue processing completed'); + + // Check status after processing + const stillPending = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .count('* as count') + .first(); + + logger.info(`Emails still pending after processing: ${stillPending.count}`); + } catch (error) { + logger.error(`Error processing queue: ${error.message}`); + logger.error(`Stack trace: ${error.stack}`); + } + } + + // 5. Check for any recent successful emails + logger.info('\n\n6. Checking recent successful emails...'); + const recentSuccess = await db('email_queue') + .where('status', 'sent') + .orderBy('sent_at', 'desc') + .limit(3); + + if (recentSuccess.length > 0) { + logger.info(`Last ${recentSuccess.length} successful emails:`); + recentSuccess.forEach((email, index) => { + logger.info(` ${index + 1}. Type: ${email.email_type}, Sent: ${email.sent_at}`); + }); + } else { + logger.info('No successfully sent emails found'); + } + + logger.info('\n\n=== Diagnostic check complete ==='); + + } catch (error) { + logger.error('Error running diagnostic check:', error); + } finally { + await db.destroy(); + process.exit(0); + } +} + +// Run the check +checkEmailProcessor(); \ No newline at end of file diff --git a/backend/scripts/check-email-templates.js b/backend/scripts/check-email-templates.js new file mode 100644 index 0000000..8f160cc --- /dev/null +++ b/backend/scripts/check-email-templates.js @@ -0,0 +1,66 @@ +const { db } = require('../src/database/db'); + +async function checkEmailTemplates() { + try { + console.log('=== Email Templates Check ===\n'); + + // 1. Check table columns + console.log('1. Checking email_templates table structure...'); + + // Check which columns exist + const columnChecks = [ + 'subject', 'subject_en', 'subject_de', + 'body_html', 'body_html_en', 'body_html_de', + 'body_text', 'body_text_en', 'body_text_de' + ]; + + const existingColumns = []; + for (const col of columnChecks) { + const exists = await db.schema.hasColumn('email_templates', col); + if (exists) existingColumns.push(col); + } + + console.log(' Existing columns:', existingColumns.join(', ')); + + // 2. Get all templates + console.log('\n2. Current email templates:'); + const templates = await db('email_templates').select('*'); + + for (const template of templates) { + console.log(`\n Template: ${template.template_key}`); + console.log(' -------------------'); + + // Check which fields have content + const fields = ['subject', 'subject_en', 'subject_de', + 'body_html', 'body_html_en', 'body_html_de', + 'body_text', 'body_text_en', 'body_text_de']; + + for (const field of fields) { + if (template[field]) { + const preview = template[field].substring(0, 50) + '...'; + console.log(` ${field}: ${preview}`); + } + } + + // Check for German translations + const hasGermanSubject = template.subject_de || template.body_html_de; + console.log(` Has German translation: ${hasGermanSubject ? 'YES' : 'NO'}`); + } + + // 3. Summary + console.log('\n3. Summary:'); + const totalTemplates = templates.length; + const templatesWithGerman = templates.filter(t => t.subject_de || t.body_html_de).length; + console.log(` Total templates: ${totalTemplates}`); + console.log(` Templates with German: ${templatesWithGerman}`); + console.log(` Missing German: ${totalTemplates - templatesWithGerman}`); + + await db.destroy(); + } catch (error) { + console.error('Error:', error); + await db.destroy(); + process.exit(1); + } +} + +checkEmailTemplates(); \ No newline at end of file diff --git a/backend/scripts/check-german-templates.js b/backend/scripts/check-german-templates.js new file mode 100644 index 0000000..7583db8 --- /dev/null +++ b/backend/scripts/check-german-templates.js @@ -0,0 +1,53 @@ +const { db } = require('../src/database/db'); + +async function checkGermanTemplates() { + try { + console.log('=== German Email Template Content Check ===\n'); + + const templates = await db('email_templates').select('*'); + + for (const template of templates) { + console.log(`\nTemplate: ${template.template_key}`); + console.log('====================================='); + + // Check German subject + console.log('\nGERMAN SUBJECT:'); + console.log(template.subject_de || 'MISSING'); + + // Check if German HTML body has English content + console.log('\nGERMAN HTML BODY:'); + const germanHtml = template.body_html_de || ''; + + // Check for English phrases in German template + const englishPhrases = [ + 'Dear', 'Gallery', 'has been', 'Your photo', 'successfully', + 'Details:', 'Link:', 'Password:', 'Expires:', 'Event Date:', + 'Thank you', 'Best regards', 'View Gallery', 'days' + ]; + + const foundEnglish = englishPhrases.filter(phrase => + germanHtml.toLowerCase().includes(phrase.toLowerCase()) + ); + + if (foundEnglish.length > 0) { + console.log('โš ๏ธ Found English phrases in German template:', foundEnglish.join(', ')); + } + + // Show first 500 chars of German HTML + console.log(germanHtml.substring(0, 500) + '...\n'); + + // Check German text body + console.log('GERMAN TEXT BODY:'); + const germanText = template.body_text_de || ''; + console.log(germanText.substring(0, 300) + '...\n'); + } + + await db.destroy(); + } catch (error) { + console.error('Error:', error); + await db.destroy(); + process.exit(1); + } +} + +checkGermanTemplates(); \ No newline at end of file diff --git a/backend/scripts/check-storage.js b/backend/scripts/check-storage.js new file mode 100755 index 0000000..a9e1391 --- /dev/null +++ b/backend/scripts/check-storage.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +/** + * Script to check storage directory structure and verify files + * Usage: node scripts/check-storage.js [eventSlug] + */ + +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../src/database/db'); + +const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); + +async function checkDirectory(dirPath, description) { + try { + await fs.access(dirPath); + const stats = await fs.stat(dirPath); + const files = await fs.readdir(dirPath); + console.log(`โœ“ ${description}: ${dirPath}`); + console.log(` - Files/Folders: ${files.length}`); + console.log(` - Permissions: ${(stats.mode & parseInt('777', 8)).toString(8)}`); + return true; + } catch (error) { + console.log(`โœ— ${description}: ${dirPath} - ${error.message}`); + return false; + } +} + +async function checkStorageStructure(eventSlug = null) { + console.log('Checking storage structure...'); + console.log(`Storage base path: ${STORAGE_PATH}\n`); + + // Check main directories + await checkDirectory(STORAGE_PATH, 'Storage root'); + await checkDirectory(path.join(STORAGE_PATH, 'events'), 'Events directory'); + await checkDirectory(path.join(STORAGE_PATH, 'events/active'), 'Active events'); + await checkDirectory(path.join(STORAGE_PATH, 'events/archived'), 'Archived events'); + await checkDirectory(path.join(STORAGE_PATH, 'thumbnails'), 'Thumbnails'); + await checkDirectory(path.join(STORAGE_PATH, 'uploads'), 'Uploads'); + + console.log('\n---\n'); + + // If event slug provided, check specific event + if (eventSlug) { + console.log(`Checking specific event: ${eventSlug}`); + + const event = await db('events').where('slug', eventSlug).first(); + if (!event) { + console.log(`โœ— Event not found in database: ${eventSlug}`); + return; + } + + console.log(`โœ“ Event found in database:`); + console.log(` - ID: ${event.id}`); + console.log(` - Name: ${event.event_name}`); + console.log(` - Active: ${event.is_active}`); + console.log(` - Archived: ${event.is_archived}`); + + // Check event directory + const eventDir = path.join(STORAGE_PATH, 'events/active', eventSlug); + const eventExists = await checkDirectory(eventDir, 'Event directory'); + + if (eventExists) { + const files = await fs.readdir(eventDir); + console.log(` - Photo files: ${files.filter(f => /\.(jpg|jpeg|png|gif)$/i.test(f)).length}`); + } + + // Check photos in database + const photos = await db('photos').where('event_id', event.id).select('id', 'filename', 'path', 'thumbnail_path'); + console.log(`\nDatabase photos: ${photos.length}`); + + // Check if photo files exist + let existingPhotos = 0; + let missingPhotos = 0; + let existingThumbnails = 0; + let missingThumbnails = 0; + + for (const photo of photos) { + const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path); + try { + await fs.access(photoPath); + existingPhotos++; + } catch { + missingPhotos++; + console.log(` โœ— Missing photo: ${photo.path}`); + } + + if (photo.thumbnail_path) { + const thumbPath = path.join(STORAGE_PATH, photo.thumbnail_path.replace(/^\//, '')); + try { + await fs.access(thumbPath); + existingThumbnails++; + } catch { + missingThumbnails++; + console.log(` โœ— Missing thumbnail: ${photo.thumbnail_path}`); + } + } + } + + console.log(`\nFile check summary:`); + console.log(` - Photos: ${existingPhotos} exist, ${missingPhotos} missing`); + console.log(` - Thumbnails: ${existingThumbnails} exist, ${missingThumbnails} missing`); + } else { + // List all event directories + try { + const activeDir = path.join(STORAGE_PATH, 'events/active'); + const eventDirs = await fs.readdir(activeDir); + console.log(`Active event directories: ${eventDirs.length}`); + for (const dir of eventDirs.slice(0, 10)) { + console.log(` - ${dir}`); + } + if (eventDirs.length > 10) { + console.log(` ... and ${eventDirs.length - 10} more`); + } + } catch (error) { + console.log('Could not list event directories:', error.message); + } + } +} + +// Parse command line arguments +const eventSlug = process.argv[2] || null; + +// Run the script +checkStorageStructure(eventSlug).then(async () => { + await db.destroy(); + console.log('\nStorage check complete'); +}).catch(async error => { + console.error('Error:', error); + await db.destroy(); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/scripts/cleanup-thumbnails.js b/backend/scripts/cleanup-thumbnails.js new file mode 100755 index 0000000..e178966 --- /dev/null +++ b/backend/scripts/cleanup-thumbnails.js @@ -0,0 +1,107 @@ +#!/usr/bin/env node + +/** + * Script to clean up orphaned and temporary thumbnails + * Usage: node scripts/cleanup-thumbnails.js [--dry-run] + */ + +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../src/database/db'); + +const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); +const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails'); + +async function cleanupThumbnails(dryRun = false) { + console.log('Starting thumbnail cleanup...'); + console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`); + console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}\n`); + + try { + // Get all thumbnail files + const files = await fs.readdir(THUMBNAILS_DIR); + console.log(`Found ${files.length} files in thumbnails directory`); + + // Get all valid thumbnail paths from database + const validThumbnails = await db('photos') + .whereNotNull('thumbnail_path') + .select('thumbnail_path'); + + const validPaths = new Set( + validThumbnails.map(t => path.basename(t.thumbnail_path)) + ); + + console.log(`Found ${validPaths.size} valid thumbnails in database\n`); + + let tempCount = 0; + let orphanedCount = 0; + let validCount = 0; + let deletedCount = 0; + + for (const file of files) { + // Skip directories + const filePath = path.join(THUMBNAILS_DIR, file); + const stats = await fs.stat(filePath); + if (stats.isDirectory()) continue; + + // Check if it's a temporary file + if (file.startsWith('thumb_temp_')) { + tempCount++; + console.log(`Temporary file: ${file}`); + + if (!dryRun) { + try { + await fs.unlink(filePath); + deletedCount++; + } catch (error) { + console.error(` Failed to delete: ${error.message}`); + } + } + } + // Check if it's an orphaned thumbnail + else if (!validPaths.has(file)) { + orphanedCount++; + console.log(`Orphaned file: ${file}`); + + if (!dryRun) { + try { + await fs.unlink(filePath); + deletedCount++; + } catch (error) { + console.error(` Failed to delete: ${error.message}`); + } + } + } else { + validCount++; + } + } + + console.log('\n--- Summary ---'); + console.log(`Total files: ${files.length}`); + console.log(`Valid thumbnails: ${validCount}`); + console.log(`Temporary files: ${tempCount}`); + console.log(`Orphaned files: ${orphanedCount}`); + if (!dryRun) { + console.log(`Deleted files: ${deletedCount}`); + } else { + console.log(`Files to be deleted: ${tempCount + orphanedCount}`); + } + + } catch (error) { + console.error('Error during cleanup:', error); + process.exit(1); + } +} + +// Parse command line arguments +const dryRun = process.argv.includes('--dry-run'); + +// Run the cleanup +cleanupThumbnails(dryRun).then(async () => { + await db.destroy(); + console.log('\nCleanup complete'); +}).catch(async error => { + console.error('Cleanup failed:', error); + await db.destroy(); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/scripts/create-admin.js b/backend/scripts/create-admin.js new file mode 100755 index 0000000..675311c --- /dev/null +++ b/backend/scripts/create-admin.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +/** + * Script to create an admin user + * Usage: node scripts/create-admin.js --email admin@example.com --username admin --password yourpassword + * + * If no password is provided, a random one will be generated and displayed + */ + +require('dotenv').config(); +const bcrypt = require('bcrypt'); +const { db } = require('../src/database/db'); +const crypto = require('crypto'); + +// Parse command line arguments +const args = process.argv.slice(2); +const getArg = (name) => { + const index = args.findIndex(arg => arg === `--${name}`); + return index !== -1 && args[index + 1] ? args[index + 1] : null; +}; + +const email = getArg('email'); +const username = getArg('username') || email?.split('@')[0] || 'admin'; +let password = getArg('password'); + +// Validate email +if (!email) { + console.error('Error: Email is required. Use --email admin@example.com'); + process.exit(1); +} + +// Generate password if not provided +if (!password) { + password = crypto.randomBytes(12).toString('base64').slice(0, 16); + console.log(`Generated password: ${password}`); + console.log('Please save this password securely!'); +} + +async function createAdmin() { + try { + // Check if user already exists + const existingUser = await db('admin_users') + .where('email', email) + .orWhere('username', username) + .first(); + + if (existingUser) { + console.error(`Error: User with email "${email}" or username "${username}" already exists`); + process.exit(1); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Create admin user + await db('admin_users').insert({ + username, + email, + password_hash: passwordHash, + is_active: true, + created_at: new Date(), + updated_at: new Date() + }); + + console.log(`โœ… Admin user created successfully!`); + console.log(` Email: ${email}`); + console.log(` Username: ${username}`); + console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`); + + process.exit(0); + } catch (error) { + console.error('Error creating admin user:', error.message); + process.exit(1); + } +} + +createAdmin(); \ No newline at end of file diff --git a/backend/scripts/create-test-event.js b/backend/scripts/create-test-event.js new file mode 100644 index 0000000..292c5ca --- /dev/null +++ b/backend/scripts/create-test-event.js @@ -0,0 +1,58 @@ +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../.env') }); +const { db } = require('../src/database/db'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { v4: uuidv4 } = require('uuid'); + +async function createTestEvent() { + try { + console.log('Creating test event...'); + + // Hash a simple password + const passwordHash = await bcrypt.hash('test123', 10); + + // Generate share token + const shareToken = uuidv4().replace(/-/g, ''); + const shareLink = `http://localhost:3005/gallery/wedding-test123-2025-07-07/${shareToken}`; + + // Create event + const eventData = { + slug: 'wedding-test123-2025-07-07', + event_type: 'wedding', + event_name: 'Test Wedding', + event_date: '2025-07-07', + host_email: 'host@example.com', + admin_email: 'admin@example.com', + password_hash: passwordHash, + welcome_message: 'Welcome to our test wedding gallery!', + color_theme: null, // Use global theme + is_active: 1, + expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + share_link: shareLink + }; + + // Delete existing event if it exists + await db('events').where('slug', eventData.slug).delete(); + + // Insert new event + const insertResult = await db('events').insert(eventData).returning('id'); + const eventId = insertResult[0]?.id || insertResult[0]; + console.log('Event created with ID:', eventId); + + console.log('\nTest event created successfully!'); + console.log('Event details:'); + console.log('- Name:', eventData.event_name); + console.log('- Slug:', eventData.slug); + console.log('- Password:', 'test123'); + console.log('- Share link:', shareLink); + console.log('\nYou can now access the gallery at the share link above'); + + process.exit(0); + } catch (error) { + console.error('Error creating test event:', error); + process.exit(1); + } +} + +createTestEvent(); \ No newline at end of file diff --git a/backend/scripts/debug-500-errors.js b/backend/scripts/debug-500-errors.js new file mode 100644 index 0000000..330017e --- /dev/null +++ b/backend/scripts/debug-500-errors.js @@ -0,0 +1,92 @@ +require('dotenv').config(); +const { db } = require('../src/database/db'); + +async function debugEndpoints() { + console.log('Debugging 500 errors...\n'); + + try { + // Test email templates query + console.log('1. Testing email templates query:'); + try { + const templates = await db('email_templates') + .select('*') + .orderBy('template_key'); + + console.log(`Found ${templates.length} templates`); + if (templates.length > 0) { + console.log('First template columns:', Object.keys(templates[0])); + console.log('Template keys:', templates.map(t => t.template_key)); + } + } catch (error) { + console.error('Email templates query failed:', error.message); + console.error('Error code:', error.code); + } + + // Test notifications query + console.log('\n2. Testing notifications query:'); + try { + const notifications = await db('activity_logs') + .select( + 'activity_logs.*', + 'events.event_name' + ) + .leftJoin('events', 'activity_logs.event_id', 'events.id') + .whereNull('activity_logs.read_at') + .orderBy('activity_logs.created_at', 'desc') + .limit(5); + + console.log(`Found ${notifications.length} unread notifications`); + } catch (error) { + console.error('Notifications query failed:', error.message); + console.error('Error code:', error.code); + + // Check if it's a column issue + if (error.message.includes('column')) { + console.log('\nChecking activity_logs columns:'); + const columns = await db('activity_logs').columnInfo(); + console.log('Columns:', Object.keys(columns)); + } + } + + // Test specific template query + console.log('\n3. Testing specific template query (gallery_created):'); + try { + const template = await db('email_templates') + .where('template_key', 'gallery_created') + .first(); + + if (template) { + console.log('Template found:', template.template_key); + console.log('Has subject_en?', template.subject_en !== undefined); + console.log('Has subject?', template.subject !== undefined); + } else { + console.log('Template not found'); + } + } catch (error) { + console.error('Template query failed:', error.message); + } + + // Check CMS pages + console.log('\n4. Checking CMS pages:'); + try { + const pages = await db('cms_pages') + .select('slug', 'title', 'is_published') + .orderBy('slug'); + + console.log(`Found ${pages.length} CMS pages:`); + pages.forEach(page => { + console.log(` - ${page.slug}: ${page.title} (published: ${page.is_published})`); + }); + } catch (error) { + console.error('CMS pages query failed:', error.message); + } + + } catch (error) { + console.error('General error:', error); + } finally { + await db.destroy(); + process.exit(0); + } +} + +debugEndpoints(); \ No newline at end of file diff --git a/backend/scripts/debug-email-queue.js b/backend/scripts/debug-email-queue.js new file mode 100644 index 0000000..ea6d76a --- /dev/null +++ b/backend/scripts/debug-email-queue.js @@ -0,0 +1,146 @@ +const { db } = require('../src/database/db'); +const winston = require('winston'); + +// Create a simple console logger +const logger = winston.createLogger({ + format: winston.format.simple(), + transports: [new winston.transports.Console()] +}); + +async function debugEmailQueue() { + try { + logger.info('=== Email Queue Debug Report ===\n'); + + // 1. Count exactly like the admin dashboard does + logger.info('1. Admin Dashboard Query (ALL pending, no retry filter):'); + const [adminCount] = await db('email_queue').where('status', 'pending').count('* as count'); + logger.info(` Pending emails (admin dashboard view): ${adminCount.count}\n`); + + // 2. Count like the email processor does + logger.info('2. Email Processor Query (pending with retry_count < 3):'); + const [processorCount] = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .count('* as count'); + logger.info(` Pending emails (processor view): ${processorCount.count}\n`); + + // 3. Show the discrepancy + logger.info('3. Discrepancy Analysis:'); + if (adminCount.count !== processorCount.count) { + logger.info(` โš ๏ธ DISCREPANCY FOUND!`); + logger.info(` Admin shows: ${adminCount.count}`); + logger.info(` Processor will process: ${processorCount.count}`); + logger.info(` Difference: ${adminCount.count - processorCount.count} email(s)\n`); + + // Find the problematic emails + logger.info('4. Emails with retry_count >= 3 (still pending):'); + const stuckEmails = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '>=', 3) + .select('*'); + + if (stuckEmails.length > 0) { + logger.info(` Found ${stuckEmails.length} stuck email(s):\n`); + stuckEmails.forEach((email, index) => { + logger.info(` Email ${index + 1}:`); + logger.info(` ID: ${email.id}`); + logger.info(` Type: ${email.email_type}`); + logger.info(` Recipient: ${email.recipient_email}`); + logger.info(` Status: ${email.status}`); + logger.info(` Retry Count: ${email.retry_count} โš ๏ธ`); + logger.info(` Created: ${email.created_at}`); + logger.info(` Last Error: ${email.error_message || 'None'}\n`); + }); + } + } else { + logger.info(` โœ… No discrepancy - counts match\n`); + } + + // 5. Show ALL pending emails with details + logger.info('5. ALL Pending Emails (regardless of retry count):'); + const allPending = await db('email_queue') + .where('status', 'pending') + .orderBy('retry_count', 'desc') + .orderBy('created_at', 'asc'); + + if (allPending.length > 0) { + allPending.forEach((email, index) => { + const willProcess = email.retry_count < 3; + logger.info(`\n Email ${index + 1}: ${willProcess ? 'โœ… WILL PROCESS' : 'โŒ STUCK (max retries)'}`); + logger.info(` ID: ${email.id}`); + logger.info(` Type: ${email.email_type}`); + logger.info(` Recipient: ${email.recipient_email}`); + logger.info(` Event ID: ${email.event_id}`); + logger.info(` Retry Count: ${email.retry_count}/3`); + logger.info(` Created: ${email.created_at}`); + logger.info(` Scheduled: ${email.scheduled_at}`); + if (email.error_message) { + logger.info(` Last Error: ${email.error_message}`); + } + }); + } else { + logger.info(' No pending emails found'); + } + + // 6. Show counts by status + logger.info('\n\n6. Email Queue Summary by Status:'); + const statusCounts = await db('email_queue') + .select('status') + .count('* as count') + .groupBy('status') + .orderBy('status'); + + statusCounts.forEach(row => { + logger.info(` ${row.status}: ${row.count}`); + }); + + // 7. Failed emails summary + logger.info('\n7. Failed Emails Summary:'); + const failedSummary = await db('email_queue') + .where('status', 'failed') + .select('retry_count') + .count('* as count') + .groupBy('retry_count') + .orderBy('retry_count'); + + if (failedSummary.length > 0) { + failedSummary.forEach(row => { + logger.info(` Retry count ${row.retry_count}: ${row.count} email(s)`); + }); + } else { + logger.info(' No failed emails'); + } + + // 8. Recommendations + logger.info('\n\n=== RECOMMENDATIONS ==='); + + if (adminCount.count > processorCount.count) { + logger.info('\nโ— You have emails stuck with retry_count >= 3'); + logger.info(' These emails will NOT be processed automatically.'); + logger.info('\n To fix this, you can:'); + logger.info(' 1. Reset retry count: UPDATE email_queue SET retry_count = 0 WHERE status = \'pending\' AND retry_count >= 3;'); + logger.info(' 2. Mark as failed: UPDATE email_queue SET status = \'failed\' WHERE status = \'pending\' AND retry_count >= 3;'); + logger.info(' 3. Delete them: DELETE FROM email_queue WHERE status = \'pending\' AND retry_count >= 3;'); + } + + const anyPending = adminCount.count > 0; + if (anyPending && processorCount.count === 0) { + logger.info('\nโ— All pending emails have exceeded retry limit'); + logger.info(' The email processor will not attempt to send them.'); + } else if (anyPending && processorCount.count > 0) { + logger.info('\nโœ… Email processor should process the pending emails on next run'); + logger.info(' Make sure the email processor service is running.'); + } + + logger.info('\n=== Debug report complete ==='); + + } catch (error) { + logger.error('Error running debug report:', error); + } finally { + await db.destroy(); + process.exit(0); + } +} + +// Run the debug +debugEmailQueue(); \ No newline at end of file diff --git a/backend/scripts/diagnose-thumbnails.js b/backend/scripts/diagnose-thumbnails.js new file mode 100755 index 0000000..afc8f04 --- /dev/null +++ b/backend/scripts/diagnose-thumbnails.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +/** + * Script to diagnose thumbnail serving issues + * Usage: node scripts/diagnose-thumbnails.js + */ + +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../src/database/db'); + +const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); +const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails'); + +async function diagnoseThumbnails(eventId) { + if (!eventId) { + console.error('Usage: node scripts/diagnose-thumbnails.js '); + process.exit(1); + } + + console.log(`Diagnosing thumbnails for event ID: ${eventId}`); + console.log(`Storage path: ${STORAGE_PATH}`); + console.log(`Thumbnails directory: ${THUMBNAILS_DIR}\n`); + + try { + // Get event info + const event = await db('events').where('id', eventId).first(); + if (!event) { + console.error(`Event not found with ID: ${eventId}`); + return; + } + + console.log(`Event: ${event.event_name} (${event.slug})`); + console.log(`Active: ${event.is_active}, Archived: ${event.is_archived}\n`); + + // Get photos for this event + const photos = await db('photos') + .where('event_id', eventId) + .select('id', 'filename', 'path', 'thumbnail_path'); + + console.log(`Found ${photos.length} photos in database\n`); + + let missingThumbnails = 0; + let existingThumbnails = 0; + let pathIssues = []; + + for (const photo of photos.slice(0, 10)) { // Check first 10 photos + console.log(`Photo ID ${photo.id}: ${photo.filename}`); + console.log(` Photo path: ${photo.path}`); + console.log(` Thumbnail path in DB: ${photo.thumbnail_path}`); + + if (photo.thumbnail_path) { + // Expected thumbnail filename + const expectedThumbName = `thumb_${photo.filename}`; + const expectedThumbPath = path.join(THUMBNAILS_DIR, expectedThumbName); + + // Check if thumbnail exists + try { + await fs.access(expectedThumbPath); + console.log(` โœ“ Thumbnail exists at: ${expectedThumbName}`); + existingThumbnails++; + + // Check if DB path matches expected path + const dbThumbName = path.basename(photo.thumbnail_path); + if (dbThumbName !== expectedThumbName) { + console.log(` โš  Path mismatch! DB has: ${dbThumbName}, Expected: ${expectedThumbName}`); + pathIssues.push({ + photoId: photo.id, + dbPath: photo.thumbnail_path, + expectedPath: `thumbnails/${expectedThumbName}` + }); + } + } catch { + console.log(` โœ— Thumbnail missing: ${expectedThumbName}`); + missingThumbnails++; + } + } else { + console.log(` โœ— No thumbnail path in database`); + missingThumbnails++; + } + console.log(''); + } + + console.log('--- Summary ---'); + console.log(`Existing thumbnails: ${existingThumbnails}`); + console.log(`Missing thumbnails: ${missingThumbnails}`); + console.log(`Path issues: ${pathIssues.length}`); + + if (pathIssues.length > 0) { + console.log('\n--- Path Issues ---'); + console.log('The following photos have incorrect thumbnail paths in the database:'); + for (const issue of pathIssues) { + console.log(`Photo ID ${issue.photoId}:`); + console.log(` Current: ${issue.dbPath}`); + console.log(` Should be: ${issue.expectedPath}`); + } + + console.log('\nTo fix path issues, run:'); + console.log(`UPDATE photos SET thumbnail_path = 'thumbnails/thumb_' || filename WHERE event_id = ${eventId};`); + } + + // Check for any thumbnails in the directory that match this event + const files = await fs.readdir(THUMBNAILS_DIR); + const eventThumbnails = files.filter(f => { + // Try to match thumbnails for this event + for (const photo of photos) { + if (f === `thumb_${photo.filename}`) return true; + } + return false; + }); + + console.log(`\n--- Filesystem Check ---`); + console.log(`Found ${eventThumbnails.length} thumbnails in directory for this event`); + + } catch (error) { + console.error('Error during diagnosis:', error); + process.exit(1); + } +} + +// Parse command line arguments +const eventId = process.argv[2] ? parseInt(process.argv[2]) : null; + +// Run the diagnosis +diagnoseThumbnails(eventId).then(async () => { + await db.destroy(); + console.log('\nDiagnosis complete'); +}).catch(async error => { + console.error('Diagnosis failed:', error); + await db.destroy(); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/scripts/fix-email-queue-schema.js b/backend/scripts/fix-email-queue-schema.js new file mode 100755 index 0000000..825e4c9 --- /dev/null +++ b/backend/scripts/fix-email-queue-schema.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +/** + * Script to diagnose and fix email_queue schema issues + * This helps resolve the "column updated_at does not exist" error + */ + +require('dotenv').config(); +const { db } = require('../src/database/db'); + +async function checkAndFixEmailQueueSchema() { + console.log('Checking email_queue table schema...'); + + try { + // Get column information + const columns = await db('email_queue').columnInfo(); + console.log('\nCurrent email_queue columns:', Object.keys(columns)); + + // Check for updated_at column + if (columns.updated_at) { + console.log('\nโš ๏ธ Found unexpected updated_at column in email_queue table!'); + console.log('This column should not exist and is causing errors.'); + + // Ask for confirmation before removing + console.log('\nRemoving updated_at column...'); + await db.schema.table('email_queue', (table) => { + table.dropColumn('updated_at'); + }); + console.log('โœ… Removed updated_at column from email_queue table'); + } else { + console.log('โœ… No updated_at column found (this is correct)'); + } + + // Verify required columns exist + const requiredColumns = [ + 'id', 'event_id', 'recipient_email', 'email_type', + 'email_data', 'status', 'scheduled_at', 'sent_at', + 'error_message', 'retry_count', 'created_at' + ]; + + const missingColumns = requiredColumns.filter(col => !columns[col]); + if (missingColumns.length > 0) { + console.log('\nโš ๏ธ Missing required columns:', missingColumns); + } else { + console.log('โœ… All required columns are present'); + } + + // Check for any database triggers + if (process.env.DATABASE_CLIENT === 'pg') { + console.log('\nChecking for PostgreSQL triggers on email_queue...'); + const triggers = await db.raw(` + SELECT trigger_name, event_manipulation, action_statement + FROM information_schema.triggers + WHERE event_object_table = 'email_queue' + AND trigger_schema = current_schema() + `); + + if (triggers.rows && triggers.rows.length > 0) { + console.log('โš ๏ธ Found triggers on email_queue table:'); + triggers.rows.forEach(trigger => { + console.log(` - ${trigger.trigger_name} (${trigger.event_manipulation})`); + }); + } else { + console.log('โœ… No triggers found on email_queue table'); + } + } + + // Test update query + console.log('\nTesting update query...'); + const testEmail = await db('email_queue') + .where('status', 'pending') + .first(); + + if (testEmail) { + try { + await db('email_queue') + .where('id', testEmail.id) + .update({ + retry_count: testEmail.retry_count + }); + console.log('โœ… Update query works correctly'); + } catch (error) { + console.log('โŒ Update query failed:', error.message); + } + } else { + console.log('โ„น๏ธ No pending emails to test with'); + } + + console.log('\nSchema check complete!'); + + } catch (error) { + console.error('Error checking schema:', error); + } finally { + await db.destroy(); + } +} + +// Run the check +checkAndFixEmailQueueSchema(); \ No newline at end of file diff --git a/backend/scripts/fix-final-german-templates.js b/backend/scripts/fix-final-german-templates.js new file mode 100644 index 0000000..6473a86 --- /dev/null +++ b/backend/scripts/fix-final-german-templates.js @@ -0,0 +1,88 @@ +const { db } = require('../src/database/db'); + +async function fixFinalGermanTemplates() { + try { + console.log('Fixing remaining English words in German templates...\n'); + + // Get all templates + const templates = await db('email_templates').select('*'); + + for (const template of templates) { + let updated = false; + let updates = {}; + + // Fix subject_de + if (template.subject_de) { + updates.subject_de = template.subject_de; + } + + // Fix body_html_de + if (template.body_html_de) { + let html = template.body_html_de; + + // Replace English words with German + html = html.replace(/Gallery-Details:/g, 'Galerie-Details:'); + html = html.replace(/Galerie-Details:/g, 'Galerie-Details:'); + html = html.replace(/Details:/g, 'Details:'); + html = html.replace(/Link:/g, 'Link:'); + html = html.replace(/Gallery-Link:/g, 'Galerie-Link:'); + html = html.replace(/Galerie-Link:/g, 'Galerie-Link:'); + html = html.replace(/Archive-Details:/g, 'Archiv-Details:'); + html = html.replace(/Archiv-Details:/g, 'Archiv-Details:'); + + if (html !== template.body_html_de) { + updates.body_html_de = html; + updated = true; + } + } + + // Fix body_text_de + if (template.body_text_de) { + let text = template.body_text_de; + + text = text.replace(/Gallery-Details:/g, 'Galerie-Details:'); + text = text.replace(/Galerie-Details:/g, 'Galerie-Details:'); + text = text.replace(/Details:/g, 'Details:'); + text = text.replace(/Link:/g, 'Link:'); + text = text.replace(/Gallery-Link:/g, 'Galerie-Link:'); + text = text.replace(/Galerie-Link:/g, 'Galerie-Link:'); + text = text.replace(/Archive-Details:/g, 'Archiv-Details:'); + text = text.replace(/Archiv-Details:/g, 'Archiv-Details:'); + + if (text !== template.body_text_de) { + updates.body_text_de = text; + updated = true; + } + } + + // Also update the non-language-specific fields to match German + if (template.body_html_de) { + updates.body_html = template.body_html_de; + } + if (template.body_text_de) { + updates.body_text = template.body_text_de; + } + if (template.subject_de) { + updates.subject = template.subject_de; + } + + if (updated || Object.keys(updates).length > 0) { + await db('email_templates') + .where('template_key', template.template_key) + .update(updates); + console.log(`โœ… Updated ${template.template_key}`); + } else { + console.log(`โญ๏ธ No changes needed for ${template.template_key}`); + } + } + + console.log('\nDone!'); + await db.destroy(); + } catch (error) { + console.error('Error:', error); + await db.destroy(); + process.exit(1); + } +} + +fixFinalGermanTemplates(); \ No newline at end of file diff --git a/backend/scripts/fix-production-issues.js b/backend/scripts/fix-production-issues.js new file mode 100644 index 0000000..5fc0c13 --- /dev/null +++ b/backend/scripts/fix-production-issues.js @@ -0,0 +1,145 @@ +require('dotenv').config(); +const { db } = require('../src/database/db'); + +async function fixProductionIssues() { + console.log('Fixing production database issues...\n'); + + try { + // 1. Check and fix email_templates structure + console.log('1. Checking email_templates structure:'); + const emailColumns = await db('email_templates').columnInfo(); + console.log('Current columns:', Object.keys(emailColumns)); + + // Check if we need to add basic columns back + const hasSubject = 'subject' in emailColumns; + const hasSubjectEn = 'subject_en' in emailColumns; + + if (hasSubjectEn && !hasSubject) { + console.log('Adding basic columns back to email_templates...'); + await db.schema.alterTable('email_templates', (table) => { + table.string('subject'); + table.text('body_html'); + table.text('body_text'); + }); + + // Copy values from _en columns + await db('email_templates').update({ + subject: db.raw('subject_en'), + body_html: db.raw('body_html_en'), + body_text: db.raw('body_text_en') + }); + console.log('Basic columns added successfully'); + } + + // 2. Ensure default templates exist + console.log('\n2. Checking email templates:'); + const templateCount = await db('email_templates').count('* as count'); + console.log('Template count:', templateCount[0].count); + + if (templateCount[0].count === 0) { + console.log('No templates found, inserting defaults...'); + const defaultTemplates = [ + { + template_key: 'gallery_created', + subject: 'Your Photo Gallery is Ready!', + body_html: '

Gallery Created Successfully

...', + body_text: 'Gallery Created Successfully...', + variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date']) + }, + { + template_key: 'expiration_warning', + subject: 'Your Photo Gallery Expires Soon', + body_html: '

Gallery Expiring Soon

...', + body_text: 'Gallery Expiring Soon...', + variables: JSON.stringify(['host_name', 'event_name', 'days_remaining', 'gallery_link']) + }, + { + template_key: 'gallery_expired', + subject: 'Your Photo Gallery Has Expired', + body_html: '

Gallery Expired

...', + body_text: 'Gallery Expired...', + variables: JSON.stringify(['host_name', 'event_name']) + }, + { + template_key: 'archive_complete', + subject: 'Gallery Archive Complete', + body_html: '

Archive Complete

...', + body_text: 'Archive Complete...', + variables: JSON.stringify(['host_name', 'event_name', 'archive_size']) + } + ]; + + for (const template of defaultTemplates) { + // Add language columns if they exist + if (hasSubjectEn) { + template.subject_en = template.subject; + template.body_html_en = template.body_html; + template.body_text_en = template.body_text; + template.subject_de = template.subject; + template.body_html_de = template.body_html; + template.body_text_de = template.body_text; + } + + await db('email_templates').insert(template); + } + console.log('Default templates inserted'); + } + + // 3. Check activity_logs structure + console.log('\n3. Checking activity_logs structure:'); + const activityColumns = await db('activity_logs').columnInfo(); + console.log('Columns:', Object.keys(activityColumns)); + + // Check if read_at exists + if (!('read_at' in activityColumns)) { + console.log('Adding read_at column to activity_logs...'); + await db.schema.alterTable('activity_logs', (table) => { + table.datetime('read_at').nullable(); + }); + console.log('read_at column added'); + } + + // 4. Check and add CMS pages + console.log('\n4. Checking CMS pages:'); + const cmsColumns = await db('cms_pages').columnInfo(); + console.log('CMS columns:', Object.keys(cmsColumns)); + + const impressum = await db('cms_pages').where('slug', 'impressum').first(); + const datenschutz = await db('cms_pages').where('slug', 'datenschutz').first(); + + if (!impressum) { + console.log('Adding Impressum page...'); + await db('cms_pages').insert({ + slug: 'impressum', + title_en: 'Legal Notice', + title_de: 'Impressum', + content_en: '

Legal Notice

Your legal information here...

', + content_de: '

Impressum

Ihre rechtlichen Informationen hier...

', + updated_at: new Date() + }); + } + + if (!datenschutz) { + console.log('Adding Datenschutz page...'); + await db('cms_pages').insert({ + slug: 'datenschutz', + title_en: 'Privacy Policy', + title_de: 'Datenschutzerklรคrung', + content_en: '

Privacy Policy

Your privacy policy here...

', + content_de: '

Datenschutzerklรคrung

Ihre Datenschutzerklรคrung hier...

', + updated_at: new Date() + }); + } + + console.log('\nโœ… All fixes applied successfully!'); + + } catch (error) { + console.error('Error fixing issues:', error); + console.error('Stack:', error.stack); + } finally { + await db.destroy(); + process.exit(0); + } +} + +fixProductionIssues(); \ No newline at end of file diff --git a/backend/scripts/fix-stuck-emails.js b/backend/scripts/fix-stuck-emails.js new file mode 100644 index 0000000..812486c --- /dev/null +++ b/backend/scripts/fix-stuck-emails.js @@ -0,0 +1,126 @@ +const { db } = require('../src/database/db'); +const winston = require('winston'); + +// Create a simple console logger +const logger = winston.createLogger({ + format: winston.format.simple(), + transports: [new winston.transports.Console()] +}); + +async function fixStuckEmails() { + try { + logger.info('=== Fix Stuck Emails Script ===\n'); + + // 1. Find stuck emails + logger.info('1. Finding stuck emails (pending with retry_count >= 3)...'); + const stuckEmails = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '>=', 3) + .select('*'); + + if (stuckEmails.length === 0) { + logger.info(' โœ… No stuck emails found!'); + logger.info('\n=== Script complete ==='); + await db.destroy(); + process.exit(0); + } + + logger.info(` Found ${stuckEmails.length} stuck email(s)\n`); + + // 2. Show details + logger.info('2. Stuck email details:'); + stuckEmails.forEach((email, index) => { + logger.info(`\n Email ${index + 1}:`); + logger.info(` ID: ${email.id}`); + logger.info(` Type: ${email.email_type}`); + logger.info(` Recipient: ${email.recipient_email}`); + logger.info(` Retry Count: ${email.retry_count}`); + logger.info(` Last Error: ${email.error_message || 'None'}`); + }); + + // 3. Ask for action + logger.info('\n\n3. Choose an action:'); + logger.info(' 1. Reset retry count to 0 (emails will be retried)'); + logger.info(' 2. Mark as failed (emails will not be retried)'); + logger.info(' 3. Delete these emails'); + logger.info(' 4. Cancel (do nothing)'); + + // Get command line argument + const action = process.argv[2]; + + if (!action || !['reset', 'fail', 'delete'].includes(action)) { + logger.info('\nโ— No valid action specified'); + logger.info('\nUsage:'); + logger.info(' node fix-stuck-emails.js reset - Reset retry count to 0'); + logger.info(' node fix-stuck-emails.js fail - Mark as failed'); + logger.info(' node fix-stuck-emails.js delete - Delete stuck emails'); + await db.destroy(); + process.exit(1); + } + + // 4. Execute action + logger.info(`\n4. Executing action: ${action.toUpperCase()}`); + + const emailIds = stuckEmails.map(e => e.id); + + switch (action) { + case 'reset': + await db('email_queue') + .whereIn('id', emailIds) + .update({ + retry_count: 0, + error_message: null + }); + logger.info(` โœ… Reset retry count for ${emailIds.length} email(s)`); + logger.info(' These emails will be processed on the next run'); + break; + + case 'fail': + await db('email_queue') + .whereIn('id', emailIds) + .update({ + status: 'failed' + }); + logger.info(` โœ… Marked ${emailIds.length} email(s) as failed`); + logger.info(' These emails will not be retried'); + break; + + case 'delete': + await db('email_queue') + .whereIn('id', emailIds) + .delete(); + logger.info(` โœ… Deleted ${emailIds.length} email(s)`); + break; + } + + // 5. Show updated counts + logger.info('\n5. Updated email queue status:'); + const [pendingCount] = await db('email_queue') + .where('status', 'pending') + .count('* as count'); + const [processableCount] = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .count('* as count'); + + logger.info(` Total pending: ${pendingCount.count}`); + logger.info(` Processable (retry < 3): ${processableCount.count}`); + + if (pendingCount.count !== processableCount.count) { + logger.info(` โš ๏ธ Still have ${pendingCount.count - processableCount.count} stuck email(s)`); + } else { + logger.info(' โœ… No stuck emails remaining'); + } + + logger.info('\n=== Script complete ==='); + + } catch (error) { + logger.error('Error:', error); + } finally { + await db.destroy(); + process.exit(0); + } +} + +// Run the fix +fixStuckEmails(); \ No newline at end of file diff --git a/backend/scripts/fix-temp-photos.js b/backend/scripts/fix-temp-photos.js new file mode 100644 index 0000000..751a431 --- /dev/null +++ b/backend/scripts/fix-temp-photos.js @@ -0,0 +1,171 @@ +require('dotenv').config({ path: '../.env' }); +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../src/database/db'); +const { generatePhotoFilename } = require('../src/utils/filenameSanitizer'); + +async function fixTempPhotos() { + console.log('Starting to fix temporary photo files...\n'); + + try { + // Find all photos with temp_ filenames + const tempPhotos = await db('photos') + .where('filename', 'like', 'temp_%') + .orderBy('event_id', 'asc') + .orderBy('category_id', 'asc') + .orderBy('id', 'asc'); + + console.log(`Found ${tempPhotos.length} photos with temporary filenames\n`); + + if (tempPhotos.length === 0) { + console.log('No temporary photos found. Exiting.'); + return; + } + + // Group photos by event and category + const grouped = {}; + for (const photo of tempPhotos) { + const key = `${photo.event_id}_${photo.category_id || 'null'}`; + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push(photo); + } + + console.log(`Processing ${Object.keys(grouped).length} event/category groups...\n`); + + // Process each group + for (const [key, photos] of Object.entries(grouped)) { + const [eventId, categoryIdStr] = key.split('_'); + const categoryId = categoryIdStr === 'null' ? null : parseInt(categoryIdStr); + + console.log(`\nProcessing Event ID: ${eventId}, Category ID: ${categoryId || 'uncategorized'}`); + console.log(`Photos in group: ${photos.length}`); + + // Get event details + const event = await db('events').where({ id: eventId }).first(); + if (!event) { + console.error(`Event ${eventId} not found! Skipping...`); + continue; + } + + // Get category details if applicable + let category = null; + let startCounter = 1; + + if (categoryId) { + category = await db('photo_categories').where({ id: categoryId }).first(); + if (!category) { + console.error(`Category ${categoryId} not found! Treating as uncategorized...`); + } else { + // Get the highest counter for this category + const maxPhoto = await db('photos') + .where({ event_id: eventId, category_id: categoryId }) + .whereNot('filename', 'like', 'temp_%') + .orderBy('id', 'desc') + .first(); + + if (maxPhoto && maxPhoto.filename) { + // Extract counter from filename + const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/); + if (match) { + startCounter = parseInt(match[1]) + 1; + } + } + } + } else { + // For uncategorized, get the highest counter + const maxPhoto = await db('photos') + .where({ event_id: eventId }) + .whereNull('category_id') + .whereNot('filename', 'like', 'temp_%') + .orderBy('id', 'desc') + .first(); + + if (maxPhoto && maxPhoto.filename) { + const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/); + if (match) { + startCounter = parseInt(match[1]) + 1; + } + } + } + + console.log(`Starting counter: ${startCounter}`); + + // Process each photo in the group + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < photos.length; i++) { + const photo = photos[i]; + const counter = startCounter + i; + + try { + // Generate new filename + const extension = path.extname(photo.filename); + const newFilename = generatePhotoFilename( + event.event_name, + category ? category.name : 'uncategorized', + counter, + extension + ); + + // Build full paths + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); + const oldPath = path.join(storagePath, 'events/active', photo.path); + const newPath = path.join(path.dirname(oldPath), newFilename); + + // Check if old file exists + try { + await fs.access(oldPath); + } catch (e) { + console.error(`File not found: ${oldPath}`); + errorCount++; + continue; + } + + // Rename the file + await fs.rename(oldPath, newPath); + + // Update database + const newRelativePath = path.relative(path.join(storagePath, 'events/active'), newPath); + await db('photos') + .where({ id: photo.id }) + .update({ + filename: newFilename, + path: newRelativePath + }); + + console.log(`โœ“ Renamed: ${photo.filename} โ†’ ${newFilename}`); + successCount++; + + } catch (error) { + console.error(`โœ— Failed to process photo ${photo.id}: ${error.message}`); + errorCount++; + } + } + + // Update category counter if needed + if (category && successCount > 0) { + const newCounter = startCounter + photos.length - 1; + await db('photo_categories') + .where({ id: categoryId }) + .update({ photo_counter: newCounter }); + console.log(`Updated category counter to ${newCounter}`); + } + + console.log(`\nGroup summary: ${successCount} successful, ${errorCount} errors`); + } + + console.log('\n=== COMPLETE ==='); + console.log('All temporary photos have been processed.'); + + } catch (error) { + console.error('Fatal error:', error); + } finally { + await db.destroy(); + } +} + +// Run the script +fixTempPhotos().catch(console.error); \ No newline at end of file diff --git a/backend/scripts/regenerate-thumbnails.js b/backend/scripts/regenerate-thumbnails.js new file mode 100755 index 0000000..3797c55 --- /dev/null +++ b/backend/scripts/regenerate-thumbnails.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node + +/** + * Script to regenerate missing thumbnails for photos in the database + * Usage: node scripts/regenerate-thumbnails.js [eventId] + */ + +const path = require('path'); +const fs = require('fs').promises; +const sharp = require('sharp'); +const { db } = require('../src/database/db'); + +// Configuration +const THUMBNAIL_SIZE = 300; +const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); +const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails'); + +async function ensureDirectoryExists(dirPath) { + try { + await fs.access(dirPath); + } catch { + await fs.mkdir(dirPath, { recursive: true }); + console.log(`Created directory: ${dirPath}`); + } +} + +async function generateThumbnail(photoPath, thumbnailPath) { + try { + await sharp(photoPath) + .resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { + fit: 'cover', + position: 'center' + }) + .jpeg({ quality: 80 }) + .toFile(thumbnailPath); + + return true; + } catch (error) { + console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message); + return false; + } +} + +async function regenerateThumbnails(eventId = null) { + try { + console.log('Starting thumbnail regeneration...'); + console.log(`Storage path: ${STORAGE_PATH}`); + console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`); + + // Ensure thumbnails directory exists + await ensureDirectoryExists(THUMBNAILS_DIR); + + // Build query + let query = db('photos') + .join('events', 'photos.event_id', 'events.id') + .select( + 'photos.id', + 'photos.filename', + 'photos.path', + 'photos.thumbnail_path', + 'events.slug as event_slug' + ); + + if (eventId) { + query = query.where('photos.event_id', eventId); + console.log(`Filtering for event ID: ${eventId}`); + } + + const photos = await query; + console.log(`Found ${photos.length} photos to process`); + + let successCount = 0; + let skipCount = 0; + let errorCount = 0; + + for (const photo of photos) { + const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path); + const thumbnailFilename = `thumb_${photo.filename}`; + const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename); + + try { + // Check if photo file exists + await fs.access(photoPath); + + // Check if thumbnail already exists + try { + await fs.access(thumbnailPath); + console.log(`Thumbnail already exists for ${photo.filename}, skipping...`); + skipCount++; + continue; + } catch { + // Thumbnail doesn't exist, generate it + } + + console.log(`Generating thumbnail for ${photo.filename}...`); + const success = await generateThumbnail(photoPath, thumbnailPath); + + if (success) { + // Update database with thumbnail path + await db('photos') + .where('id', photo.id) + .update({ + thumbnail_path: `thumbnails/${thumbnailFilename}` + }); + + successCount++; + console.log(`โœ“ Generated thumbnail for ${photo.filename}`); + } else { + errorCount++; + } + } catch (error) { + console.error(`โœ— Photo file not found: ${photoPath}`); + errorCount++; + } + } + + console.log('\nThumbnail regeneration complete!'); + console.log(`- Successfully generated: ${successCount}`); + console.log(`- Skipped (already exist): ${skipCount}`); + console.log(`- Errors: ${errorCount}`); + console.log(`- Total processed: ${photos.length}`); + + } catch (error) { + console.error('Error during thumbnail regeneration:', error); + process.exit(1); + } finally { + await db.destroy(); + } +} + +// Parse command line arguments +const eventId = process.argv[2] ? parseInt(process.argv[2]) : null; + +// Run the script +regenerateThumbnails(eventId).then(() => { + console.log('Script completed successfully'); + process.exit(0); +}).catch(error => { + console.error('Script failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/scripts/reset-admin-password.js b/backend/scripts/reset-admin-password.js new file mode 100755 index 0000000..a6e8a6e --- /dev/null +++ b/backend/scripts/reset-admin-password.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +const bcrypt = require('bcrypt'); +const { db } = require('../src/database/db'); +const { generateReadablePassword } = require('../src/utils/passwordGenerator'); +const fs = require('fs').promises; +const path = require('path'); +const readline = require('readline'); + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +async function question(prompt) { + return new Promise((resolve) => { + rl.question(prompt, resolve); + }); +} + +async function resetAdminPassword() { + console.log('\n========================================'); + console.log('PicPeak Admin Password Reset Tool'); + console.log('========================================\n'); + + try { + // Check if admin user exists + const admin = await db('admin_users') + .where({ username: 'admin' }) + .first(); + + if (!admin) { + console.error('โŒ No admin user found in the database.'); + console.log('Run migrations first: npm run migrate'); + process.exit(1); + } + + console.log('Found admin user:', admin.username); + console.log('Email:', admin.email); + console.log('\nThis will reset the password for this admin account.'); + + const confirm = await question('\nDo you want to continue? (yes/no): '); + + if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') { + console.log('\nโŒ Password reset cancelled.'); + process.exit(0); + } + + // Generate new password + const newPassword = generateReadablePassword(); + const passwordHash = await bcrypt.hash(newPassword, 12); + + // Update the admin user + await db('admin_users') + .where({ username: 'admin' }) + .update({ + password_hash: passwordHash, + must_change_password: true, + updated_at: new Date() + }); + + // Save to file + const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt'); + const resetInfo = ` +======================================== +PicPeak Admin Password Reset +======================================== + +Password has been reset for admin account: + +Username: admin +New Password: ${newPassword} + +IMPORTANT: +1. You MUST change this password on next login +2. This file contains sensitive information +3. Delete this file after noting the password + +Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin + +Reset performed on: ${new Date().toISOString()} +======================================== +`; + + await fs.writeFile(resetInfoPath, resetInfo, 'utf8'); + + console.log('\nโœ… Password reset successful!\n'); + console.log('========================================'); + console.log('New Credentials:'); + console.log('========================================'); + console.log('Username: admin'); + console.log(`Password: ${newPassword}`); + console.log('\nโš ๏ธ IMPORTANT:'); + console.log('1. You will be required to change this password on next login'); + console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt'); + console.log('3. Delete the file after noting the password'); + console.log('========================================\n'); + + } catch (error) { + console.error('โŒ Error resetting password:', error.message); + process.exit(1); + } finally { + rl.close(); + process.exit(0); + } +} + +// Run the reset +resetAdminPassword(); \ No newline at end of file diff --git a/backend/scripts/run-email-processor.js b/backend/scripts/run-email-processor.js new file mode 100644 index 0000000..e229a4e --- /dev/null +++ b/backend/scripts/run-email-processor.js @@ -0,0 +1,111 @@ +#!/usr/bin/env node + +const { db } = require('../src/database/db'); +const { + initializeTransporter, + processEmailQueue, + testEmailConnection +} = require('../src/services/emailProcessor'); +const winston = require('winston'); + +// Create a simple console logger +const logger = winston.createLogger({ + format: winston.format.simple(), + transports: [new winston.transports.Console()] +}); + +async function runEmailProcessor(runOnce = false) { + try { + logger.info('=== Starting Email Processor ===\n'); + + // Initialize transporter + logger.info('Initializing email transporter...'); + await initializeTransporter(); + + // Test connection + logger.info('Testing email connection...'); + const connectionOk = await testEmailConnection(); + + if (!connectionOk) { + logger.error('Email connection test failed! Check your SMTP configuration.'); + logger.info('\nRequired environment variables:'); + logger.info('- SMTP_HOST'); + logger.info('- SMTP_PORT'); + logger.info('- SMTP_USER'); + logger.info('- SMTP_PASS'); + logger.info('- SMTP_FROM'); + process.exit(1); + } + + logger.info('Email connection test successful!\n'); + + if (runOnce) { + // Process queue once + logger.info('Processing email queue once...'); + await processEmailQueue(); + logger.info('Email processing complete'); + + // Show final status + const pendingCount = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .count('* as count') + .first(); + + logger.info(`\nEmails still pending: ${pendingCount.count}`); + + await db.destroy(); + process.exit(0); + } else { + // Run continuously + logger.info('Starting continuous email processor...'); + logger.info('Processing emails every 60 seconds. Press Ctrl+C to stop.\n'); + + // Process immediately + await processEmailQueue(); + + // Then every minute + setInterval(async () => { + try { + await processEmailQueue(); + } catch (error) { + logger.error('Error processing email queue:', error); + } + }, 60000); + } + + } catch (error) { + logger.error('Fatal error:', error); + await db.destroy(); + process.exit(1); + } +} + +// Handle graceful shutdown +process.on('SIGINT', async () => { + logger.info('\n\nShutting down email processor...'); + await db.destroy(); + process.exit(0); +}); + +// Check command line arguments +const args = process.argv.slice(2); +const runOnce = args.includes('--once') || args.includes('-o'); + +if (args.includes('--help') || args.includes('-h')) { + console.log(` +Email Processor Runner + +Usage: node run-email-processor.js [options] + +Options: + --once, -o Process the email queue once and exit + --help, -h Show this help message + +By default, the processor runs continuously, checking for emails every 60 seconds. + `); + process.exit(0); +} + +// Run the processor +runEmailProcessor(runOnce); \ No newline at end of file diff --git a/backend/scripts/run-migrations.js b/backend/scripts/run-migrations.js new file mode 100644 index 0000000..92019fc --- /dev/null +++ b/backend/scripts/run-migrations.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +/** + * Run database migrations using existing db connection + */ + +const { db } = require('../src/database/db'); + +async function runMigrations() { + console.log('Running database migrations...\n'); + + try { + // Run all pending migrations + const result = await db.migrate.latest({ + directory: './migrations' + }); + + if (result[1].length === 0) { + console.log('โœ“ Database is already up to date'); + } else { + console.log(`โœ“ Ran ${result[1].length} migrations:`); + result[1].forEach(migration => { + console.log(` - ${migration}`); + }); + } + + // Show current migration status + const list = await db.migrate.list(); + console.log(`\nCurrent status: ${list[0].length} completed migrations`); + + await db.destroy(); + process.exit(0); + } catch (error) { + console.error('Migration error:', error); + await db.destroy(); + process.exit(1); + } +} + +runMigrations(); \ No newline at end of file diff --git a/backend/scripts/test-cms-formatting.js b/backend/scripts/test-cms-formatting.js new file mode 100644 index 0000000..b650c77 --- /dev/null +++ b/backend/scripts/test-cms-formatting.js @@ -0,0 +1,52 @@ +/** + * Test script to verify CMS and email formatting improvements + */ + +const { formatWelcomeMessage, nl2br } = require('../src/utils/formatters'); + +console.log('Testing CMS and Email Formatting Improvements\n'); + +// Test 1: Basic line break conversion +console.log('Test 1: Basic line break conversion'); +const basicText = `Hello, +This is line 1. +This is line 2. + +This is line 4 with an extra break.`; + +console.log('Input:'); +console.log(basicText); +console.log('\nOutput (nl2br):'); +console.log(nl2br(basicText)); +console.log('\n---\n'); + +// Test 2: Welcome message formatting +console.log('Test 2: Welcome message formatting'); +const welcomeMessage = `Dear guests, + +We're so excited to share these special moments with you! + +Please note: +- Download your photos before the expiration date +- The password is case-sensitive +- Contact us if you have any issues + +Thank you for being part of our special day! + +Best regards, +Sarah & John`; + +console.log('Input:'); +console.log(welcomeMessage); +console.log('\nOutput (formatWelcomeMessage):'); +console.log(formatWelcomeMessage(welcomeMessage)); +console.log('\n---\n'); + +// Test 3: Empty and edge cases +console.log('Test 3: Edge cases'); +console.log('Empty string:', formatWelcomeMessage('')); +console.log('Null:', formatWelcomeMessage(null)); +console.log('Only spaces:', formatWelcomeMessage(' \n \n ')); +console.log('Single line:', formatWelcomeMessage('This is a single line message')); + +console.log('\nAll tests completed!'); \ No newline at end of file diff --git a/backend/scripts/test-german-emails.js b/backend/scripts/test-german-emails.js new file mode 100644 index 0000000..8b34f5b --- /dev/null +++ b/backend/scripts/test-german-emails.js @@ -0,0 +1,85 @@ +const { db } = require('../src/database/db'); +const { processTemplate } = require('../src/services/emailProcessor'); + +async function testGermanEmails() { + try { + console.log('=== Testing German Email Templates ===\n'); + + // Test variables + const testVars = { + host_name: 'Max Mustermann', + event_name: 'Hochzeit Schmidt', + event_date: '15.07.2024', + gallery_link: 'https://example.com/gallery/test', + gallery_password: 'test1234', + expiry_date: '15.08.2024', + days_remaining: '7', + welcome_message: 'Herzlich willkommen zu unserer Hochzeitsgalerie!', + archive_size: '250 MB', + archive_date: '16.08.2024', + photo_count: '347', + admin_email: 'support@example.com', + eventId: 1 + }; + + const templates = await db('email_templates').select('*'); + + for (const template of templates) { + console.log(`\n========== ${template.template_key.toUpperCase()} ==========`); + + // Process German version + const germanResult = await processGermanTemplate(template, testVars); + + console.log('\n--- GERMAN VERSION ---'); + console.log('Subject:', germanResult.subject); + console.log('\nHTML Preview (first 500 chars):'); + console.log(germanResult.htmlBody.substring(0, 500) + '...\n'); + + // Check for any remaining English text + const englishWords = ['Dear', 'Gallery', 'Details:', 'Link:', 'Password:', 'days', 'Thank you']; + const foundEnglish = englishWords.filter(word => + germanResult.htmlBody.includes(word) || germanResult.subject.includes(word) + ); + + if (foundEnglish.length > 0) { + console.log('โš ๏ธ WARNING: Found English words:', foundEnglish.join(', ')); + } else { + console.log('โœ… No English words found in German template'); + } + } + + await db.destroy(); + } catch (error) { + console.error('Error:', error); + await db.destroy(); + process.exit(1); + } +} + +async function processGermanTemplate(template, variables) { + // Process template as German + const subjectField = 'subject_de'; + const htmlField = 'body_html_de'; + const textField = 'body_text_de'; + + let subject = template[subjectField] || template.subject || ''; + let htmlBody = template[htmlField] || template.body_html || ''; + let textBody = template[textField] || template.body_text || ''; + + // Replace variables + Object.keys(variables).forEach(key => { + const regex = new RegExp(`{{${key}}}`, 'g'); + subject = subject.replace(regex, variables[key]); + htmlBody = htmlBody.replace(regex, variables[key]); + textBody = textBody.replace(regex, variables[key]); + }); + + // Handle conditionals (simplified) + htmlBody = htmlBody.replace(/{{#if welcome_message}}[\s\S]*?{{\/if}}/g, (match) => { + return variables.welcome_message ? match.replace(/{{#if welcome_message}}|{{\/if}}/g, '') : ''; + }); + + return { subject, htmlBody, textBody }; +} + +testGermanEmails(); \ No newline at end of file diff --git a/backend/scripts/test-photo-auth.js b/backend/scripts/test-photo-auth.js new file mode 100755 index 0000000..955f269 --- /dev/null +++ b/backend/scripts/test-photo-auth.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +/** + * Script to test photo authentication + * Usage: node scripts/test-photo-auth.js + */ + +const axios = require('axios'); + +async function testPhotoAuth(token) { + if (!token) { + console.error('Usage: node scripts/test-photo-auth.js '); + console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_'); + process.exit(1); + } + + const baseUrl = process.env.API_URL || 'http://localhost:3001'; + + console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`); + console.log(`Base URL: ${baseUrl}\n`); + + // Test URLs + const tests = [ + { + name: 'Thumbnail via static route', + url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`, + headers: { 'Authorization': `Bearer ${token}` } + }, + { + name: 'Photo via static route', + url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`, + headers: { 'Authorization': `Bearer ${token}` } + }, + { + name: 'Gallery photos API', + url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`, + headers: { 'Authorization': `Bearer ${token}` } + } + ]; + + for (const test of tests) { + console.log(`Testing: ${test.name}`); + console.log(`URL: ${test.url}`); + + try { + const response = await axios.get(test.url, { + headers: test.headers, + validateStatus: () => true // Don't throw on any status + }); + + console.log(`Status: ${response.status}`); + console.log(`Headers:`, response.headers['content-type']); + + if (response.status === 200) { + if (test.name.includes('API')) { + console.log(`Photos count: ${response.data.photos?.length || 0}`); + } else { + console.log(`Content length: ${response.headers['content-length']} bytes`); + } + } else { + console.log(`Error:`, response.data); + } + } catch (error) { + console.log(`Network error:`, error.message); + } + + console.log('---\n'); + } + + // Decode token to show info + try { + const parts = token.split('.'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + console.log('Token payload:', payload); + } catch (error) { + console.log('Failed to decode token'); + } +} + +// Get token from command line +const token = process.argv[2]; + +testPhotoAuth(token).catch(error => { + console.error('Test failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/scripts/test-security-logging.js b/backend/scripts/test-security-logging.js new file mode 100644 index 0000000..407c74d --- /dev/null +++ b/backend/scripts/test-security-logging.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +/** + * Test script to verify security logging is working correctly + * Run with: node scripts/test-security-logging.js + */ + +require('dotenv').config({ path: '../.env' }); +const logger = require('../src/utils/logger'); + +console.log('Testing Security Logging...\n'); + +// Test 1: Basic logging +console.log('1. Testing basic logging levels:'); +logger.info('Test info message', { test: true }); +logger.warn('Test warning message', { test: true }); +logger.error('Test error message', { test: true }); + +// Test 2: Security event logging +console.log('\n2. Testing security event logging:'); + +// Rate limit exceeded +logger.warn('Rate limit exceeded', { + ip: '192.168.1.100', + path: '/api/admin/login', + method: 'POST', + authenticated: false, + userAgent: 'Mozilla/5.0 Test', + timestamp: new Date().toISOString(), + rateLimitInfo: { + limit: 5, + current: 6, + remaining: 0, + resetTime: new Date(Date.now() + 900000).toISOString() + } +}); + +// Auth rate limit +logger.warn('Auth rate limit exceeded', { + ip: '192.168.1.101', + path: '/api/auth/admin/login', + method: 'POST', + userAgent: 'Mozilla/5.0 Test', + authType: 'admin', + timestamp: new Date().toISOString() +}); + +// Failed login +logger.warn('Failed login attempt', { + username: 'testuser', + ip: '192.168.1.102', + userAgent: 'Mozilla/5.0 Test', + reason: 'invalid_credentials', + timestamp: new Date().toISOString() +}); + +// JWT validation failure +logger.warn('JWT validation failed', { + ip: '192.168.1.103', + path: '/api/admin/events', + method: 'GET', + userAgent: 'Mozilla/5.0 Test', + error: 'TokenExpiredError', + message: 'jwt expired', + timestamp: new Date().toISOString() +}); + +// Account lockout +logger.warn('Login attempt on locked account', { + username: 'lockeduser', + ip: '192.168.1.104', + remainingLockTime: 1200, + timestamp: new Date().toISOString() +}); + +// Suspicious activity +logger.warn('Suspicious login activity detected', { + username: 'suspicioususer', + ips: ['192.168.1.105', '192.168.1.106', '192.168.1.107'], + timeWindow: '15 minutes', + timestamp: new Date().toISOString() +}); + +console.log('\n3. Check log files:'); +console.log('- logs/security.log - Should contain all security warnings'); +console.log('- logs/error.log - Should contain error messages'); +console.log('- logs/combined.log - Should contain all messages'); + +console.log('\nโœ… Security logging test complete!'); +console.log('Review the log files to ensure all events are properly captured.'); + +// Give logger time to flush +setTimeout(() => { + process.exit(0); +}, 1000); \ No newline at end of file diff --git a/backend/scripts/verify-template-equality.js b/backend/scripts/verify-template-equality.js new file mode 100644 index 0000000..c922797 --- /dev/null +++ b/backend/scripts/verify-template-equality.js @@ -0,0 +1,110 @@ +const { db } = require('../src/database/db'); + +async function verifyTemplateEquality() { + try { + console.log('Verifying template equality between German and English versions...\n'); + + const templates = await db('email_templates').select('*'); + + for (const template of templates) { + console.log(`\n=== ${template.template_key.toUpperCase()} ===`); + + // Check subject length similarity + const subjectEnLength = template.subject_en?.length || 0; + const subjectDeLength = template.subject_de?.length || 0; + console.log(`Subject length - EN: ${subjectEnLength}, DE: ${subjectDeLength}`); + + // Check HTML content features + const htmlEn = template.body_html_en || ''; + const htmlDe = template.body_html_de || ''; + + // Check for key features in both versions + const features = [ + { name: 'Handlebars conditionals', pattern: /{{#if/g }, + { name: 'Styled divs', pattern: /style="/g }, + { name: 'Background colors', pattern: /background-color:/g }, + { name: 'Buttons/CTAs', pattern: //g }, + { name: 'Icons/Emojis', pattern: /[๐Ÿ“ง๐Ÿ“žโœ…โš ๏ธ]/g }, + { name: 'Lists', pattern: /
    /g } + ]; + + console.log('\nFeature comparison:'); + for (const feature of features) { + const enCount = (htmlEn.match(feature.pattern) || []).length; + const deCount = (htmlDe.match(feature.pattern) || []).length; + const status = enCount === deCount ? 'โœ…' : 'โŒ'; + console.log(`${status} ${feature.name}: EN=${enCount}, DE=${deCount}`); + } + + // Check text content length + const textEn = template.body_text_en || ''; + const textDe = template.body_text_de || ''; + console.log(`\nText content length - EN: ${textEn.length}, DE: ${textDe.length}`); + + // Check for specific variables usage + const variables = [ + 'host_name', 'event_name', 'event_date', 'gallery_link', + 'gallery_password', 'expiry_date', 'welcome_message', + 'days_remaining', 'support_email', 'support_phone', + 'archive_date', 'photo_count', 'archive_size' + ]; + + const missingInEn = []; + const missingInDe = []; + + for (const variable of variables) { + const varPattern = new RegExp(`{{${variable}}}`, 'g'); + const inEn = varPattern.test(htmlEn) || varPattern.test(textEn); + const inDe = varPattern.test(htmlDe) || varPattern.test(textDe); + + if (inDe && !inEn) missingInEn.push(variable); + if (inEn && !inDe) missingInDe.push(variable); + } + + if (missingInEn.length > 0) { + console.log(`\nโš ๏ธ Variables in DE but missing in EN: ${missingInEn.join(', ')}`); + } + if (missingInDe.length > 0) { + console.log(`\nโš ๏ธ Variables in EN but missing in DE: ${missingInDe.join(', ')}`); + } + + // Overall quality score + const enScore = [ + htmlEn.includes('style='), + htmlEn.includes('{{#if'), + htmlEn.includes('background-color'), + htmlEn.includes(''), + htmlEn.includes('margin:'), + htmlEn.includes('padding:') + ].filter(Boolean).length; + + const deScore = [ + htmlDe.includes('style='), + htmlDe.includes('{{#if'), + htmlDe.includes('background-color'), + htmlDe.includes(''), + htmlDe.includes('margin:'), + htmlDe.includes('padding:') + ].filter(Boolean).length; + + console.log(`\nQuality score (out of 6) - EN: ${enScore}, DE: ${deScore}`); + console.log(enScore === deScore ? 'โœ… Templates have equal quality!' : 'โŒ Quality mismatch'); + } + + console.log('\n\nSummary:'); + console.log('The English templates have been updated to match the German templates in:'); + console.log('- HTML styling and structure'); + console.log('- Conditional content blocks'); + console.log('- Visual elements (buttons, alerts, icons)'); + console.log('- Information completeness'); + console.log('- Professional formatting'); + + } catch (error) { + console.error('Error:', error); + } finally { + await db.destroy(); + } +} + +verifyTemplateEquality(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..1939007 --- /dev/null +++ b/backend/server.js @@ -0,0 +1,260 @@ +require('dotenv').config(); + +// Validate critical environment variables before proceeding +const { validateEnvironment } = require('./src/config/validateEnv'); +validateEnvironment(); + +// Initialize logger early to capture startup logs +const logger = require('./src/utils/logger'); +logger.info('Server starting up', { + nodeVersion: process.version, + environment: process.env.NODE_ENV || 'development', + timestamp: new Date().toISOString() +}); + +const express = require('express'); +const helmet = require('helmet'); +const cors = require('cors'); +const path = require('path'); +const { initializeDatabase, db } = require('./src/database/db'); +const { startFileWatcher } = require('./src/services/fileWatcher'); +const { startExpirationChecker } = require('./src/services/expirationChecker'); +const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor'); +const { maintenanceMiddleware } = require('./src/middleware/maintenance'); +const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); +const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService'); +const logger = require('./src/utils/logger'); + +// Import routes +const authRoutes = require('./src/routes/auth-enhanced'); +const eventRoutes = require('./src/routes/events'); +const galleryRoutes = require('./src/routes/gallery'); +const adminRoutes = require('./src/routes/admin'); +const adminAuthRoutes = require('./src/routes/adminAuth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Trust proxy headers (required for Traefik/nginx) +// Set to specific number of proxies or loopback to be more secure +app.set('trust proxy', 'loopback, linklocal, uniquelocal'); + +// Security middleware with custom CSP +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React + styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components + imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images + connectSrc: ["'self'"], // API connections + fontSrc: ["'self'", "https:", "data:"], // Web fonts + objectSrc: ["'none'"], // Disable plugins + mediaSrc: ["'self'"], // Audio/video + frameSrc: ["'none'"], // Disable iframes + }, + }, + hsts: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true + }, + permittedCrossDomainPolicies: false, + referrerPolicy: { policy: "strict-origin-when-cross-origin" } +})); + +// Additional security headers +app.use((req, res, next) => { + // Permissions Policy (controls browser features) + res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()'); + next(); +}); + +// CORS configuration +const corsOptions = { + origin: function (origin, callback) { + const allowedOrigins = [ + process.env.FRONTEND_URL || 'http://localhost:3005', + process.env.ADMIN_URL || 'http://localhost:3005' + ]; + + // In development, also allow localhost origins + if (process.env.NODE_ENV === 'development') { + allowedOrigins.push( + 'http://localhost:5173', // Vite dev server + 'http://localhost:3002', // Backend server + 'http://localhost:3001', // For API testing + 'http://localhost:3000' // Direct backend access + ); + } + + // Allow requests with no origin (like mobile apps or curl) + if (!origin || allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true +}; + +app.use(cors(corsOptions)); + +// Initialize rate limiters (they will be created dynamically) +let generalRateLimiter; +let authRateLimiter; + +// Function to initialize rate limiters +async function initializeRateLimiters() { + generalRateLimiter = await createRateLimiter(); + authRateLimiter = await createAuthRateLimiter(); + + // Apply rate limiting + app.use('/api/', generalRateLimiter); + app.use('/api/auth', authRateLimiter); + app.use('/api/gallery/:slug/verify', authRateLimiter); + app.use('/api/admin/auth/login', authRateLimiter); +} + +// Note: Rate limiters will be initialized after database connection + +// Body parsing middleware with increased limits for large uploads +app.use(express.json({ limit: '100mb' })); +app.use(express.urlencoded({ extended: true, limit: '100mb' })); + +// Maintenance mode middleware - add after body parsing but before routes +app.use(maintenanceMiddleware); + +// Session timeout middleware for admin routes +app.use('/api/admin', sessionTimeoutMiddleware); + +// Middleware to set CORS headers for static files +const setCorsHeaders = (req, res, next) => { + res.header('Access-Control-Allow-Origin', req.headers.origin || '*'); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Cross-Origin-Resource-Policy', 'cross-origin'); + next(); +}; + +// Import secure static middleware +const secureStatic = require('./src/middleware/secureStatic'); + +// Get storage path from environment or use default +const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage'); + +// Static file serving for photos (protected) +app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active'))); + +// Static file serving for thumbnails (protected) +app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails'))); + +// Static file serving for uploads (public - logos, favicons) +app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads'))); + +// Debug endpoint to check IP detection (only in development) +if (process.env.NODE_ENV === 'development') { + app.get('/api/debug/ip', (req, res) => { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + + res.json({ + detectedIp: clientIp, + reqIp: req.ip, + headers: { + 'x-forwarded-for': req.headers['x-forwarded-for'], + 'x-real-ip': req.headers['x-real-ip'], + 'x-forwarded-proto': req.headers['x-forwarded-proto'], + 'x-forwarded-host': req.headers['x-forwarded-host'] + }, + trustProxy: app.get('trust proxy') + }); + }); +} + +// Health check endpoint +app.get('/health', async (req, res) => { + try { + // Check database connectivity + await db.raw('SELECT 1'); + + res.json({ + status: 'ok', + database: 'connected', + timestamp: new Date().toISOString() + }); + } catch (error) { + logger.error('Health check failed:', error); + res.status(503).json({ + status: 'error', + database: 'disconnected', + error: error.message, + timestamp: new Date().toISOString() + }); + } +}); + +// Routes +app.use('/api/auth', authRoutes); +app.use('/api/events', eventRoutes); +app.use('/api/gallery', galleryRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/admin/auth', adminAuthRoutes); +app.use('/api/admin/system', require('./src/routes/adminSystem')); +app.use('/api/public/settings', require('./src/routes/publicSettings')); +app.use('/api/public', require('./src/routes/publicCMS')); +app.use('/api/images', require('./src/routes/protectedImages')); + +// Error handling middleware +app.use((err, req, res, next) => { + logger.error(err.stack); + res.status(500).json({ error: 'Something went wrong!' }); +}); + +// Initialize services +async function startServer() { + try { + // Initialize database + await initializeDatabase(); + + // Initialize rate limiters after database is ready + await initializeRateLimiters(); + logger.info('Rate limiters initialized with database configuration'); + + // Initialize auth security cleanup job + const { initializeCleanupJob } = require('./src/utils/authSecurity'); + initializeCleanupJob(); + + // Initialize temp upload cleanup job + const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads'); + // Run cleanup on startup + cleanupTempUploads(); + // Schedule periodic cleanup every hour + setInterval(cleanupTempUploads, 60 * 60 * 1000); + logger.info('Temp upload cleanup scheduled'); + + // Start file watcher + startFileWatcher(); + + // Start expiration checker + startExpirationChecker(); + + // Initialize email transporter and start queue processor + await initializeTransporter(); + startEmailQueueProcessor(); + + app.listen(PORT, () => { + logger.info(`Server running on port ${PORT}`); + logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`); + logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`); + }); + } catch (error) { + logger.error('Failed to start server:', error); + process.exit(1); + } +} + +startServer(); + +module.exports = app; // For testing diff --git a/backend/src/__tests__/dbCompat.test.js b/backend/src/__tests__/dbCompat.test.js new file mode 100644 index 0000000..372e428 --- /dev/null +++ b/backend/src/__tests__/dbCompat.test.js @@ -0,0 +1,83 @@ +const { formatBoolean, isPostgreSQL, addDays, formatDateForDB, insertAndGetId } = require('../utils/dbCompat'); + +describe('Database Compatibility', () => { + // Save original env + const originalEnv = process.env.DATABASE_CLIENT; + + afterEach(() => { + // Restore original env after each test + if (originalEnv) { + process.env.DATABASE_CLIENT = originalEnv; + } else { + delete process.env.DATABASE_CLIENT; + } + }); + + describe('formatBoolean', () => { + test('should format boolean values correctly', () => { + // Mock for SQLite + process.env.DATABASE_CLIENT = 'sqlite3'; + expect(formatBoolean(true)).toBe(1); + expect(formatBoolean(false)).toBe(0); + + // Mock for PostgreSQL + process.env.DATABASE_CLIENT = 'pg'; + expect(formatBoolean(true)).toBe(true); + expect(formatBoolean(false)).toBe(false); + + // Default (no env var) should be SQLite + delete process.env.DATABASE_CLIENT; + expect(formatBoolean(true)).toBe(1); + expect(formatBoolean(false)).toBe(0); + }); + }); + + describe('isPostgreSQL', () => { + test('should detect PostgreSQL correctly', () => { + process.env.DATABASE_CLIENT = 'pg'; + expect(isPostgreSQL()).toBe(true); + + process.env.DATABASE_CLIENT = 'sqlite3'; + expect(isPostgreSQL()).toBe(false); + + delete process.env.DATABASE_CLIENT; + expect(isPostgreSQL()).toBe(false); // Default to SQLite + }); + }); + + describe('formatDateForDB', () => { + test('should format dates as ISO strings', () => { + const date = new Date('2024-01-15T10:30:00Z'); + expect(formatDateForDB(date)).toBe('2024-01-15T10:30:00.000Z'); + }); + }); + + describe('addDays', () => { + test('should add days correctly', () => { + const date = new Date('2024-01-15'); + const result = addDays(date, 30); + expect(result.toISOString().split('T')[0]).toBe('2024-02-14'); + + const negativeResult = addDays(date, -7); + expect(negativeResult.toISOString().split('T')[0]).toBe('2024-01-08'); + }); + }); + + describe('insertAndGetId', () => { + test('should handle PostgreSQL result format', async () => { + const mockQuery = { + returning: jest.fn().mockResolvedValue([{ id: 123 }]) + }; + const result = await insertAndGetId(mockQuery); + expect(result).toBe(123); + }); + + test('should handle SQLite result format', async () => { + const mockQuery = { + returning: jest.fn().mockResolvedValue([456]) + }; + const result = await insertAndGetId(mockQuery); + expect(result).toBe(456); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/config/storage.js b/backend/src/config/storage.js new file mode 100644 index 0000000..1f2603f --- /dev/null +++ b/backend/src/config/storage.js @@ -0,0 +1,8 @@ +const path = require('path'); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +module.exports = { + getStoragePath +}; \ No newline at end of file diff --git a/backend/src/config/validateEnv.js b/backend/src/config/validateEnv.js new file mode 100644 index 0000000..90789c3 --- /dev/null +++ b/backend/src/config/validateEnv.js @@ -0,0 +1,66 @@ +const logger = require('../utils/logger'); + +/** + * Validates required environment variables are set + * Exits the process if critical variables are missing + */ +function validateEnvironment() { + const requiredVars = [ + { + name: 'JWT_SECRET', + description: 'Secret key for JWT token signing', + critical: true + } + ]; + + const warnings = []; + const errors = []; + + // Check each required variable + requiredVars.forEach(({ name, description, critical }) => { + const value = process.env[name]; + + if (!value || value.trim() === '') { + const message = `Missing required environment variable: ${name} - ${description}`; + + if (critical) { + errors.push(message); + } else { + warnings.push(message); + } + } + + // Additional validation for JWT_SECRET + if (name === 'JWT_SECRET' && value) { + // Check for the insecure default value + if (value === 'your-secret-key') { + errors.push('CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.'); + } + + // Check minimum length (should be at least 32 characters for security) + if (value.length < 32) { + warnings.push(`JWT_SECRET should be at least 32 characters long for better security (current: ${value.length} characters)`); + } + } + }); + + // Log warnings + warnings.forEach(warning => logger.warn(warning)); + + // If there are critical errors, log them and exit + if (errors.length > 0) { + logger.error('=== CRITICAL CONFIGURATION ERRORS ==='); + errors.forEach(error => logger.error(error)); + logger.error('====================================='); + logger.error('Server cannot start due to missing or invalid configuration.'); + logger.error('Please set the required environment variables and try again.'); + + // Exit with error code + process.exit(1); + } + + // Log successful validation + logger.info('Environment validation passed'); +} + +module.exports = { validateEnvironment }; \ No newline at end of file diff --git a/backend/src/database/connection-manager.js b/backend/src/database/connection-manager.js new file mode 100644 index 0000000..68dae7d --- /dev/null +++ b/backend/src/database/connection-manager.js @@ -0,0 +1,132 @@ +const knex = require('knex'); +const knexConfig = require('../../knexfile'); +const logger = require('../utils/logger'); + +class ConnectionManager { + constructor() { + this.db = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 10; + this.reconnectDelay = 5000; // 5 seconds + this.isReconnecting = false; + } + + async initialize() { + try { + this.db = knex(knexConfig); + + // Test the connection + await this.db.raw('SELECT 1'); + logger.info('Database connection established successfully'); + + // Set up connection error handling + this.setupErrorHandling(); + + this.reconnectAttempts = 0; + return this.db; + } catch (error) { + logger.error('Failed to initialize database connection:', error); + throw error; + } + } + + setupErrorHandling() { + if (!this.db) return; + + // Handle connection errors + this.db.on('error', async (error) => { + logger.error('Database connection error:', error); + + if (this.shouldReconnect(error)) { + await this.reconnect(); + } + }); + } + + shouldReconnect(error) { + const reconnectableErrors = [ + 'ECONNREFUSED', + 'ETIMEDOUT', + 'ECONNRESET', + 'Connection terminated unexpectedly', + 'Connection terminated' + ]; + + return reconnectableErrors.some(msg => + error.code === msg || error.message?.includes(msg) + ); + } + + async reconnect() { + if (this.isReconnecting) { + logger.info('Already attempting to reconnect...'); + return; + } + + this.isReconnecting = true; + + while (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + + logger.info(`Attempting to reconnect to database (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); + + try { + // Destroy the old connection pool + if (this.db) { + await this.db.destroy(); + } + + // Create new connection + await this.initialize(); + + logger.info('Successfully reconnected to database'); + this.isReconnecting = false; + return; + } catch (error) { + logger.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error.message); + + if (this.reconnectAttempts < this.maxReconnectAttempts) { + await new Promise(resolve => setTimeout(resolve, this.reconnectDelay)); + } + } + } + + this.isReconnecting = false; + logger.error('Failed to reconnect to database after maximum attempts'); + + // In production, you might want to alert monitoring systems or restart the process + if (process.env.NODE_ENV === 'production') { + logger.error('Exiting process due to database connection failure'); + process.exit(1); + } + } + + getConnection() { + if (!this.db) { + throw new Error('Database connection not initialized'); + } + return this.db; + } + + async healthCheck() { + try { + await this.db.raw('SELECT 1'); + return { healthy: true }; + } catch (error) { + logger.error('Database health check failed:', error); + return { healthy: false, error: error.message }; + } + } + + async destroy() { + if (this.db) { + await this.db.destroy(); + this.db = null; + } + } +} + +// Create singleton instance +const connectionManager = new ConnectionManager(); + +module.exports = connectionManager; \ No newline at end of file diff --git a/backend/src/database/db.js b/backend/src/database/db.js new file mode 100644 index 0000000..5eacda8 --- /dev/null +++ b/backend/src/database/db.js @@ -0,0 +1,228 @@ +const knex = require('knex'); +const knexConfig = require('../../knexfile'); + +// Create database connection with built-in retry logic +const db = knex(knexConfig); + +async function initializeDatabase() { + // Events table + const hasEventsTable = await db.schema.hasTable('events'); + if (!hasEventsTable) { + await db.schema.createTable('events', (table) => { + table.increments('id').primary(); + table.string('slug').unique().notNullable(); + table.string('event_type').notNullable(); + table.string('event_name').notNullable(); + table.date('event_date').notNullable(); + table.string('host_email').notNullable(); + table.string('admin_email').notNullable(); + table.string('password_hash').notNullable(); + table.text('welcome_message'); + table.text('color_theme'); + table.string('share_link').unique().notNullable(); + table.datetime('created_at').defaultTo(db.fn.now()); + table.datetime('expires_at').notNullable(); + table.boolean('is_active').defaultTo(true); + table.boolean('is_archived').defaultTo(false); + table.string('archive_path'); + table.datetime('archived_at'); + }); + } else { + // Check if color_theme needs to be updated to TEXT type + // This is needed for larger theme configurations + const isPostgres = knexConfig.client === 'pg'; + + if (!isPostgres) { + // SQLite-specific migration + try { + await db.raw(` + CREATE TABLE IF NOT EXISTS events_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT UNIQUE NOT NULL, + event_type TEXT NOT NULL, + event_name TEXT NOT NULL, + event_date DATE NOT NULL, + host_email TEXT NOT NULL, + admin_email TEXT NOT NULL, + password_hash TEXT NOT NULL, + welcome_message TEXT, + color_theme TEXT, + share_link TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + is_active BOOLEAN DEFAULT 1, + is_archived BOOLEAN DEFAULT 0, + archive_path TEXT, + archived_at DATETIME, + allow_user_uploads BOOLEAN DEFAULT 0, + upload_category_id INTEGER + ) + `); + + await db.raw('INSERT INTO events_new SELECT * FROM events'); + await db.raw('DROP TABLE events'); + await db.raw('ALTER TABLE events_new RENAME TO events'); + } catch (error) { + // If the migration fails, it might already have been applied + console.log('Color theme migration may have already been applied'); + } + } + } + + // Photo metadata table + const hasPhotosTable = await db.schema.hasTable('photos'); + if (!hasPhotosTable) { + await db.schema.createTable('photos', (table) => { + table.increments('id').primary(); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.string('filename').notNullable(); + table.string('path').notNullable(); + table.string('thumbnail_path'); + table.string('type').notNullable(); // 'collage' or 'individual' + table.integer('size_bytes'); + table.datetime('uploaded_at').defaultTo(db.fn.now()); + table.integer('view_count').defaultTo(0); + table.integer('download_count').defaultTo(0); + }); + } + + // Access logs table + const hasAccessLogsTable = await db.schema.hasTable('access_logs'); + if (!hasAccessLogsTable) { + await db.schema.createTable('access_logs', (table) => { + table.increments('id').primary(); + table.integer('event_id').references('id').inTable('events'); + table.string('ip_address'); + table.string('user_agent'); + table.string('action'); // 'view', 'download', 'login_success', 'login_fail' + table.string('photo_id'); + table.datetime('timestamp').defaultTo(db.fn.now()); + }); + } + + // Email queue table + const hasEmailQueueTable = await db.schema.hasTable('email_queue'); + if (!hasEmailQueueTable) { + await db.schema.createTable('email_queue', (table) => { + table.increments('id').primary(); + table.integer('event_id').references('id').inTable('events'); + table.string('recipient_email').notNullable(); + table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete' + table.json('email_data'); + table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed' + table.datetime('scheduled_at').defaultTo(db.fn.now()); + table.datetime('sent_at'); + table.text('error_message'); + table.integer('retry_count').defaultTo(0); + }); + } + + // Admin users table + const hasAdminUsersTable = await db.schema.hasTable('admin_users'); + if (!hasAdminUsersTable) { + await db.schema.createTable('admin_users', (table) => { + table.increments('id').primary(); + table.string('username').unique().notNullable(); + table.string('email').unique().notNullable(); + table.string('password_hash').notNullable(); + table.boolean('is_active').defaultTo(true); + table.datetime('created_at').defaultTo(db.fn.now()); + table.datetime('updated_at').defaultTo(db.fn.now()); + table.datetime('last_login'); + }); + } else { + // Check if updated_at column exists + const hasUpdatedAt = await db.schema.hasColumn('admin_users', 'updated_at'); + if (!hasUpdatedAt) { + await db.schema.table('admin_users', (table) => { + table.datetime('updated_at'); + }); + // Set default value for existing rows + await db('admin_users').update({ updated_at: new Date() }); + } + } + + // Email configuration table + const hasEmailConfigTable = await db.schema.hasTable('email_configs'); + if (!hasEmailConfigTable) { + await db.schema.createTable('email_configs', (table) => { + table.increments('id').primary(); + table.string('smtp_host').notNullable(); + table.integer('smtp_port').notNullable(); + table.boolean('smtp_secure').defaultTo(false); + table.string('smtp_user'); + table.string('smtp_pass'); + table.string('from_email').notNullable(); + table.string('from_name'); + table.datetime('updated_at').defaultTo(db.fn.now()); + }); + } + + // Email templates table + const hasEmailTemplatesTable = await db.schema.hasTable('email_templates'); + if (!hasEmailTemplatesTable) { + await db.schema.createTable('email_templates', (table) => { + table.increments('id').primary(); + table.string('template_key').unique().notNullable(); // 'gallery_created', 'expiration_warning', etc. + table.string('subject').notNullable(); + table.text('body_html').notNullable(); + table.text('body_text'); + table.json('variables'); // Available template variables + table.datetime('updated_at').defaultTo(db.fn.now()); + }); + } + + // App settings table + const hasAppSettingsTable = await db.schema.hasTable('app_settings'); + if (!hasAppSettingsTable) { + await db.schema.createTable('app_settings', (table) => { + table.increments('id').primary(); + table.string('setting_key').unique().notNullable(); + table.json('setting_value'); + table.string('setting_type'); // 'branding', 'theme', 'general' + table.datetime('updated_at').defaultTo(db.fn.now()); + }); + } + + // Activity logs table + const hasActivityLogsTable = await db.schema.hasTable('activity_logs'); + if (!hasActivityLogsTable) { + await db.schema.createTable('activity_logs', (table) => { + table.increments('id').primary(); + table.string('activity_type').notNullable(); // 'event_created', 'photos_uploaded', etc. + table.string('actor_type'); // 'admin', 'system', 'guest' + table.integer('actor_id'); + table.string('actor_name'); + table.json('metadata'); // Additional data about the activity + table.integer('event_id').references('id').inTable('events'); + table.datetime('created_at').defaultTo(db.fn.now()); + table.datetime('read_at').nullable(); + }); + } else { + // Check if read_at column exists + const hasReadAt = await db.schema.hasColumn('activity_logs', 'read_at'); + if (!hasReadAt) { + await db.schema.table('activity_logs', (table) => { + table.datetime('read_at').nullable(); + }); + } + } +} + +// Helper function to log activities +async function logActivity(activityType, metadata = {}, eventId = null, actor = null) { + try { + await db('activity_logs').insert({ + activity_type: activityType, + actor_type: actor?.type || 'system', + actor_id: actor?.id || null, + actor_name: actor?.name || null, + metadata: JSON.stringify(metadata), + event_id: eventId + }); + } catch (error) { + console.error('Failed to log activity:', error); + } +} + +module.exports = { db, initializeDatabase, logActivity }; \ No newline at end of file diff --git a/backend/src/middleware/auth-enhanced-v2.js b/backend/src/middleware/auth-enhanced-v2.js new file mode 100644 index 0000000..28176e0 --- /dev/null +++ b/backend/src/middleware/auth-enhanced-v2.js @@ -0,0 +1,167 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { isTokenRevoked } = require('../utils/tokenRevocation'); +const logger = require('../utils/logger'); + +/** + * Enhanced admin authentication middleware with revocation checking + */ +async function adminAuth(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + complete: true + }); + decoded = decoded.payload; + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); + } + return res.status(401).json({ error: 'Invalid token' }); + } + + // Check if token is revoked + if (await isTokenRevoked(decoded)) { + logger.warn('Revoked token used', { + userId: decoded.id, + tokenType: decoded.type + }); + return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' }); + } + + // Verify token type + if (decoded.type !== 'admin') { + logger.warn('Non-admin token used for admin endpoint', { + userId: decoded.id, + tokenType: decoded.type + }); + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + // IP validation (optional - can be strict or just log) + const currentIp = req.ip || req.connection.remoteAddress; + if (decoded.ip && decoded.ip !== currentIp) { + logger.warn('Token used from different IP', { + userId: decoded.id, + tokenIp: decoded.ip, + currentIp: currentIp + }); + } + + // Check if admin still exists and is active + const admin = await db('admin_users') + .where({ id: decoded.id, is_active: formatBoolean(true) }) + .first(); + + if (!admin) { + return res.status(401).json({ error: 'Invalid token' }); + } + + // Check if password was changed after token was issued + if (admin.password_changed_at) { + const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; + if (decoded.iat < passwordChangedTime) { + logger.warn('Token used after password change', { userId: decoded.id }); + return res.status(401).json({ + error: 'Token invalid due to password change', + code: 'PASSWORD_CHANGED' + }); + } + } + + // Add user info to request + req.admin = { + id: admin.id, + username: admin.username, + email: admin.email + }; + req.token = token; // Store token for potential revocation + + next(); + } catch (error) { + logger.error('Auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +/** + * Enhanced gallery authentication middleware with revocation checking + */ +async function galleryAuth(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + complete: true + }); + decoded = decoded.payload; + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' }); + } + return res.status(401).json({ error: 'Invalid session' }); + } + + // Check if token is revoked + if (await isTokenRevoked(decoded)) { + return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' }); + } + + // Verify token type + if (decoded.type !== 'gallery') { + return res.status(403).json({ error: 'Invalid access token' }); + } + + // Check if event still exists and is active + const event = await db('events') + .where({ + id: decoded.eventId, + is_active: true, + is_archived: false + }) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found or expired' }); + } + + // Check if gallery has expired + if (new Date(event.expires_at) < new Date()) { + return res.status(410).json({ + error: 'Gallery has expired', + code: 'GALLERY_EXPIRED' + }); + } + + // Add event info to request + req.event = event; + req.galleryToken = decoded; + req.token = token; + + next(); + } catch (error) { + logger.error('Gallery auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +// Export other middleware functions from original file... +module.exports = { + adminAuth, + galleryAuth, + // ... other exports +}; \ No newline at end of file diff --git a/backend/src/middleware/auth-enhanced.js b/backend/src/middleware/auth-enhanced.js new file mode 100644 index 0000000..29c1000 --- /dev/null +++ b/backend/src/middleware/auth-enhanced.js @@ -0,0 +1,238 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const logger = require('../utils/logger'); + +/** + * Enhanced admin authentication middleware + * Adds additional security checks beyond basic JWT validation + */ +async function adminAuth(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + complete: true + }); + decoded = decoded.payload; // Extract payload when using complete: true + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); + } + return res.status(401).json({ error: 'Invalid token' }); + } + + // Verify token type + if (decoded.type !== 'admin') { + logger.warn('Non-admin token used for admin endpoint', { + userId: decoded.id, + tokenType: decoded.type + }); + return res.status(403).json({ error: 'Insufficient permissions' }); + } + + // IP validation (optional - can be strict or just log) + const currentIp = req.ip || req.connection.remoteAddress; + if (decoded.ip && decoded.ip !== currentIp) { + logger.warn('Token used from different IP', { + userId: decoded.id, + tokenIp: decoded.ip, + currentIp: currentIp + }); + // Optional: Reject if IP doesn't match + // return res.status(401).json({ error: 'Invalid token' }); + } + + // Check if admin still exists and is active + const admin = await db('admin_users') + .where({ id: decoded.id, is_active: formatBoolean(true) }) + .first(); + + if (!admin) { + return res.status(401).json({ error: 'Invalid token' }); + } + + // Check if password was changed after token was issued + if (admin.password_changed_at) { + const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; + if (decoded.iat < passwordChangedTime) { + logger.warn('Token used after password change', { userId: decoded.id }); + return res.status(401).json({ + error: 'Token invalid due to password change', + code: 'PASSWORD_CHANGED' + }); + } + } + + // Add user info to request + req.admin = { + id: admin.id, + username: admin.username, + email: admin.email + }; + + next(); + } catch (error) { + logger.error('Auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +/** + * Enhanced gallery authentication middleware + */ +async function galleryAuth(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + complete: true + }); + decoded = decoded.payload; + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' }); + } + return res.status(401).json({ error: 'Invalid session' }); + } + + // Verify token type + if (decoded.type !== 'gallery') { + return res.status(403).json({ error: 'Invalid access token' }); + } + + // Check if event still exists and is active + const event = await db('events') + .where({ + id: decoded.eventId, + is_active: true, + is_archived: false + }) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found or expired' }); + } + + // Check if gallery has expired + if (new Date(event.expires_at) < new Date()) { + return res.status(410).json({ + error: 'Gallery has expired', + code: 'GALLERY_EXPIRED' + }); + } + + // Add event info to request + req.event = event; + req.galleryToken = decoded; + + next(); + } catch (error) { + logger.error('Gallery auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +/** + * Photo access authentication + * Validates both admin and gallery tokens for photo access + */ +async function photoAuth(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'Authentication required' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } catch (err) { + return res.status(401).json({ error: 'Invalid token' }); + } + + // Allow both admin and gallery tokens + if (decoded.type === 'admin') { + const admin = await db('admin_users') + .where({ id: decoded.id, is_active: formatBoolean(true) }) + .first(); + + if (!admin) { + return res.status(401).json({ error: 'Invalid token' }); + } + + req.auth = { type: 'admin', user: admin }; + } else if (decoded.type === 'gallery') { + const event = await db('events') + .where({ + id: decoded.eventId, + is_active: true, + is_archived: false + }) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + // For gallery tokens, ensure they can only access their event's photos + req.auth = { type: 'gallery', event: event }; + } else { + return res.status(403).json({ error: 'Invalid token type' }); + } + + next(); + } catch (error) { + logger.error('Photo auth middleware error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +/** + * Verify gallery access for specific operations + */ +async function verifyGalleryAccess(req, res, next) { + try { + if (!req.auth) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const { eventId } = req.params; + + // Admins can access any gallery + if (req.auth.type === 'admin') { + return next(); + } + + // Gallery tokens can only access their own event + if (req.auth.type === 'gallery') { + if (req.auth.event.id !== parseInt(eventId)) { + return res.status(403).json({ error: 'Access denied' }); + } + return next(); + } + + res.status(403).json({ error: 'Access denied' }); + } catch (error) { + res.status(500).json({ error: 'Access verification failed' }); + } +} + +module.exports = { + adminAuth, + galleryAuth, + photoAuth, + verifyGalleryAccess +}; \ No newline at end of file diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..24e10a6 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,85 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const logger = require('../utils/logger'); + +async function adminAuth(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + logger.warn('Admin auth attempt without token', { + ip: clientIp, + path: req.path, + method: req.method, + userAgent: req.headers['user-agent'] + }); + return res.status(401).json({ error: 'No token provided' }); + } + + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } catch (jwtError) { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + + logger.warn('JWT validation failed', { + ip: clientIp, + path: req.path, + method: req.method, + userAgent: req.headers['user-agent'], + error: jwtError.name, + message: jwtError.message, + timestamp: new Date().toISOString() + }); + + if (jwtError.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired' }); + } + return res.status(401).json({ error: 'Invalid token' }); + } + + const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first(); + + if (!admin) { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + + logger.warn('Admin auth failed - user not found or inactive', { + ip: clientIp, + userId: decoded.id, + path: req.path, + method: req.method, + timestamp: new Date().toISOString() + }); + return res.status(401).json({ error: 'Invalid token' }); + } + + req.admin = admin; + next(); + } catch (error) { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + + logger.error('Admin auth middleware error', { + ip: clientIp, + path: req.path, + error: error.message, + stack: error.stack, + timestamp: new Date().toISOString() + }); + res.status(401).json({ error: 'Invalid token' }); + } +} + +module.exports = { adminAuth }; diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js new file mode 100644 index 0000000..1852629 --- /dev/null +++ b/backend/src/middleware/gallery.js @@ -0,0 +1,36 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); + +// Middleware to verify gallery access +async function verifyGalleryAccess(req, res, next) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const event = await db('events') + .where({ + id: decoded.eventId, + is_active: formatBoolean(true), + is_archived: formatBoolean(false) + }) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found or expired' }); + } + + req.event = event; + next(); + } catch (error) { + console.error('Error verifying gallery access:', error); + res.status(401).json({ error: 'Invalid token', details: error.message }); + } +} + +module.exports = { + verifyGalleryAccess +}; \ No newline at end of file diff --git a/backend/src/middleware/maintenance.js b/backend/src/middleware/maintenance.js new file mode 100644 index 0000000..e3be0e0 --- /dev/null +++ b/backend/src/middleware/maintenance.js @@ -0,0 +1,111 @@ +const { db } = require('../database/db'); + +// Cache maintenance mode status to avoid DB queries on every request +let maintenanceMode = false; +let lastCheck = 0; +const CACHE_DURATION = 60000; // 1 minute + +// Retry configuration for database queries +const MAX_RETRIES = 3; +const RETRY_DELAY = 1000; // 1 second + +async function queryWithRetry(queryFn, retries = MAX_RETRIES) { + for (let i = 0; i < retries; i++) { + try { + return await queryFn(); + } catch (error) { + if (i === retries - 1) { + throw error; + } + + // Check if it's a connection error that might benefit from retry + const isConnectionError = + error.message?.includes('Connection terminated') || + error.message?.includes('ECONNREFUSED') || + error.message?.includes('ETIMEDOUT') || + error.code === 'ECONNRESET'; + + if (isConnectionError) { + console.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`); + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); + } else { + throw error; // Don't retry non-connection errors + } + } + } +} + +async function checkMaintenanceMode() { + const now = Date.now(); + + // Use cached value if recent + if (now - lastCheck < CACHE_DURATION) { + return maintenanceMode; + } + + try { + const setting = await queryWithRetry(async () => { + return await db('app_settings') + .where('setting_key', 'general_maintenance_mode') + .where('setting_type', 'general') + .first(); + }); + + maintenanceMode = setting ? (setting.setting_value === 'true' || setting.setting_value === true) : false; + lastCheck = now; + + return maintenanceMode; + } catch (error) { + console.error('Error checking maintenance mode after retries:', error.message); + // Return cached value or false if no cache + return maintenanceMode; + } +} + +// Middleware to enforce maintenance mode +async function maintenanceMiddleware(req, res, next) { + // Skip maintenance check for certain paths + const skipPaths = [ + '/api/admin/login', + '/api/admin/auth/login', + '/api/public/settings', + '/health' + ]; + + // Allow static assets (uploads, favicons, logos) + const isStaticAsset = req.path.startsWith('/uploads/') || + req.path.startsWith('/favicons/') || + req.path.startsWith('/logos/'); + + // Allow admin routes if admin is authenticated + const isAdminRoute = req.path.startsWith('/api/admin'); + const hasAdminAuth = req.headers.authorization?.startsWith('Bearer '); + + if (skipPaths.includes(req.path) || isStaticAsset || (isAdminRoute && hasAdminAuth)) { + return next(); + } + + try { + const inMaintenance = await checkMaintenanceMode(); + + if (inMaintenance && !isAdminRoute) { + return res.status(503).json({ + error: 'Service Unavailable', + message: 'The system is currently undergoing maintenance. Please try again later.', + maintenance: true + }); + } + } catch (error) { + // If we can't check maintenance mode, allow the request to proceed + console.error('Failed to check maintenance mode, allowing request:', error.message); + } + + next(); +} + +// Function to clear cache when settings change +function clearMaintenanceCache() { + lastCheck = 0; +} + +module.exports = { maintenanceMiddleware, clearMaintenanceCache }; \ No newline at end of file diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js new file mode 100644 index 0000000..63d974e --- /dev/null +++ b/backend/src/middleware/photoAuth.js @@ -0,0 +1,116 @@ +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); + +async function photoAuth(req, res, next) { + try { + // Extract event slug from the path + let eventSlug; + + console.log('PhotoAuth middleware - path:', req.path); + + // For thumbnails, we need to parse the filename to get the event info + if (req.path.startsWith('/thumb_')) { + // For now, we'll rely on JWT token for thumbnail access + eventSlug = null; + } else { + // For regular photos, the slug is the first part of the path + eventSlug = req.path.split('/')[1]; + } + + // First check for JWT token (from gallery access) + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.replace('Bearer ', ''); + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Check if it's a gallery token + if (decoded.type === 'gallery') { + // For thumbnails, we need to verify the token is for a valid event + if (!eventSlug) { + // Extract event ID from the decoded token + if (decoded.eventId) { + const event = await db('events') + .where({ id: decoded.eventId, is_active: formatBoolean(true) }) + .first(); + if (event) { + req.event = event; + return next(); + } + } + // Fallback to slug + const event = await db('events') + .where({ slug: decoded.eventSlug, is_active: formatBoolean(true) }) + .first(); + if (event) { + req.event = event; + return next(); + } + } + // For regular photos, check if token matches the event + else if (decoded.eventSlug === eventSlug) { + const event = await db('events') + .where({ slug: eventSlug, is_active: formatBoolean(true) }) + .first(); + if (event) { + req.event = event; + return next(); + } + } + } + + // Check if it's an admin token (admins can view all photos) + if (decoded.type === 'admin') { + // For both thumbnails and photos with admin token, allow access + return next(); + } + } catch (err) { + // Token invalid, fall through to password check + console.error('JWT verification failed:', err.message); + } + } + + // Check for password header (legacy support) + const password = req.headers['x-gallery-password']; + + if (!password && !authHeader) { + return res.status(401).json({ error: 'Authentication required' }); + } + + // If no eventSlug (thumbnails), and we don't have valid auth yet, deny access + if (!eventSlug && !password) { + return res.status(401).json({ error: 'Authentication required for thumbnails' }); + } + + const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first(); + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + if (password) { + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await db('access_logs').insert({ + event_id: event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid password' }); + } + } else { + // No valid authentication + return res.status(401).json({ error: 'Invalid authentication' }); + } + + req.event = event; + next(); + } catch (error) { + console.error('Photo auth error:', error); + res.status(500).json({ error: 'Authentication error' }); + } +} + +module.exports = photoAuth; diff --git a/backend/src/middleware/secureStatic.js b/backend/src/middleware/secureStatic.js new file mode 100644 index 0000000..185d9d4 --- /dev/null +++ b/backend/src/middleware/secureStatic.js @@ -0,0 +1,46 @@ +const path = require('path'); +const express = require('express'); +const { safePathJoin, isPathSafe } = require('../utils/fileSecurityUtils'); + +/** + * Create a secure static file serving middleware that prevents path traversal attacks + * @param {string} basePath - The base directory to serve files from + * @param {Object} options - Express static options + * @returns {Function} - Express middleware + */ +function secureStatic(basePath, options = {}) { + const normalizedBase = path.resolve(basePath); + + return (req, res, next) => { + // Get the requested file path - remove leading slash for validation + const requestedPath = req.path.startsWith('/') ? req.path.substring(1) : req.path; + + // Validate the path doesn't contain dangerous patterns + if (!isPathSafe(requestedPath)) { + console.warn(`Potential path traversal attempt blocked: ${requestedPath}`); + return res.status(403).json({ error: 'Access denied' }); + } + + try { + // Validate the full path is within the base directory + const fullPath = safePathJoin(normalizedBase, requestedPath); + + // If validation passes, use express.static + const staticMiddleware = express.static(normalizedBase, { + ...options, + // Disable directory listing for security + index: false, + // Don't allow dotfiles + dotfiles: 'deny' + }); + + return staticMiddleware(req, res, next); + } catch (error) { + // Path traversal detected + console.error(`Path traversal blocked: ${requestedPath}`, error.message); + return res.status(403).json({ error: 'Access denied' }); + } + }; +} + +module.exports = secureStatic; \ No newline at end of file diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js new file mode 100644 index 0000000..a3966ec --- /dev/null +++ b/backend/src/middleware/sessionTimeout.js @@ -0,0 +1,153 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); + +// In-memory session tracking (in production, use Redis) +const sessions = new Map(); + +// Default session timeout (60 minutes) +const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000; + +// Cache for session timeout setting +let cachedTimeout = null; +let cacheExpiry = 0; +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + +// Clean up expired sessions every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [token, lastActivity] of sessions.entries()) { + if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) { + sessions.delete(token); + } + } +}, 5 * 60 * 1000); + +async function getSessionTimeout() { + const now = Date.now(); + + // Return cached value if still valid + if (cachedTimeout && now < cacheExpiry) { + return cachedTimeout; + } + + try { + const setting = await db('app_settings') + .where('setting_key', 'security_session_timeout_minutes') + .first() + .timeout(5000); // 5 second timeout + + if (setting && setting.setting_value) { + let value = setting.setting_value; + // Handle both string and object values + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch (e) { + // If it's not JSON, try to parse as number directly + value = parseInt(value); + } + } + const minutes = parseInt(value); + if (!isNaN(minutes) && minutes > 0) { + cachedTimeout = minutes * 60 * 1000; // Convert to milliseconds + cacheExpiry = now + CACHE_DURATION; + return cachedTimeout; + } + } + } catch (error) { + // Only log if it's not a connection error (to avoid spam) + if (error.code !== 'ECONNRESET' && !error.message?.includes('Connection terminated')) { + console.error('Error getting session timeout:', error.message); + } + } + + // Use cached value if available, otherwise default + return cachedTimeout || DEFAULT_SESSION_TIMEOUT; +} + +async function sessionTimeoutMiddleware(req, res, next) { + // Skip for non-authenticated routes + if (!req.headers.authorization) { + return next(); + } + + const token = req.headers.authorization.split(' ')[1]; + if (!token) { + return next(); + } + + try { + // Verify token is valid + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Check if this is an admin token + if (!decoded.id) { + return next(); + } + + const now = Date.now(); + const lastActivity = sessions.get(token); + const timeout = await getSessionTimeout(); + + // If session exists, check if it's expired + if (lastActivity) { + if (now - lastActivity > timeout) { + sessions.delete(token); + return res.status(401).json({ + error: 'Session expired', + code: 'SESSION_TIMEOUT' + }); + } + } + + // Update last activity + sessions.set(token, now); + + // Clean up old token if user has a new one + // This prevents memory leaks from token renewals + const userId = decoded.id; + for (const [oldToken, _] of sessions.entries()) { + if (oldToken !== token) { + try { + const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET); + if (oldDecoded.id === userId) { + sessions.delete(oldToken); + } + } catch (e) { + // Token is invalid, remove it + sessions.delete(oldToken); + } + } + } + + next(); + } catch (error) { + // Token is invalid + next(); + } +} + +// Function to end a session +function endSession(token) { + sessions.delete(token); +} + +// Function to get active sessions count +function getActiveSessions() { + const now = Date.now(); + let active = 0; + + for (const [_, lastActivity] of sessions.entries()) { + if (now - lastActivity <= DEFAULT_SESSION_TIMEOUT) { + active++; + } + } + + return active; +} + +module.exports = { + sessionTimeoutMiddleware, + endSession, + getActiveSessions +}; \ No newline at end of file diff --git a/backend/src/middleware/uploadValidation.js b/backend/src/middleware/uploadValidation.js new file mode 100644 index 0000000..fb4e10a --- /dev/null +++ b/backend/src/middleware/uploadValidation.js @@ -0,0 +1,111 @@ +const fs = require('fs').promises; +const path = require('path'); +const sharp = require('sharp'); +const logger = require('../utils/logger'); + +/** + * Validate uploaded file is complete and not corrupted + */ +async function validateUploadedFile(filePath) { + try { + // Check file exists and has size + const stats = await fs.stat(filePath); + if (stats.size === 0) { + throw new Error('File is empty'); + } + + // For image files, verify they can be read by Sharp + const ext = path.extname(filePath).toLowerCase(); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + + if (imageExtensions.includes(ext)) { + // Try to read metadata - this will fail if image is corrupted + let metadata; + try { + metadata = await sharp(filePath, { + failOnError: false, // Don't fail on recoverable errors + limitInputPixels: 268402689 // ~16k x 16k max + }).metadata(); + } catch (metadataError) { + // If metadata reading fails, the file is likely incomplete + throw new Error(`Invalid image file: ${metadataError.message}`); + } + + if (!metadata || !metadata.width || !metadata.height) { + throw new Error('Invalid image dimensions - file may be incomplete'); + } + + // Check for reasonable dimensions + if (metadata.width < 10 || metadata.height < 10) { + throw new Error('Image dimensions too small'); + } + + // Additional check: verify we can actually decode a small portion of the image + try { + await sharp(filePath, { + failOnError: false, + limitInputPixels: 268402689 + }) + .resize(10, 10) // Try to resize to very small size + .toBuffer(); + } catch (decodeError) { + throw new Error(`Image decode failed - file may be corrupted: ${decodeError.message}`); + } + + return true; + } + + return true; + } catch (error) { + logger.error(`File validation failed for ${filePath}:`, error.message); + throw error; + } +} + +/** + * Middleware to validate uploaded files after multer processing + */ +async function validateUploadedFiles(req, res, next) { + if (!req.files || req.files.length === 0) { + return next(); + } + + const validFiles = []; + const invalidFiles = []; + + // Validate each file + for (const file of req.files) { + try { + await validateUploadedFile(file.path); + validFiles.push(file); + } catch (error) { + logger.warn(`Removing invalid upload ${file.originalname}: ${error.message}`); + invalidFiles.push({ + filename: file.originalname, + error: error.message + }); + + // Delete the invalid file + try { + await fs.unlink(file.path); + } catch (unlinkErr) { + logger.error(`Failed to delete invalid file ${file.path}:`, unlinkErr.message); + } + } + } + + // Update req.files to only include valid files + req.files = validFiles; + + // Store invalid files info for response + if (invalidFiles.length > 0) { + req.invalidFiles = invalidFiles; + } + + next(); +} + +module.exports = { + validateUploadedFile, + validateUploadedFiles +}; \ No newline at end of file diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..7bfa3cb --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,26 @@ +const express = require('express'); +const router = express.Router(); + +// Import sub-routers +const dashboardRoutes = require('./adminDashboard'); +const archiveRoutes = require('./adminArchives'); +const emailRoutes = require('./adminEmail'); +const settingsRoutes = require('./adminSettings'); +const eventsRoutes = require('./adminEvents'); +const photosRoutes = require('./adminPhotos'); +const categoriesRoutes = require('./adminCategories'); +const cmsRoutes = require('./adminCMS'); +const notificationsRoutes = require('./adminNotifications'); + +// Mount sub-routers +router.use('/dashboard', dashboardRoutes); +router.use('/archives', archiveRoutes); +router.use('/email', emailRoutes); +router.use('/settings', settingsRoutes); +router.use('/events', eventsRoutes); +router.use('/events', photosRoutes); +router.use('/categories', categoriesRoutes); +router.use('/cms', cmsRoutes); +router.use('/notifications', notificationsRoutes); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js new file mode 100644 index 0000000..9bd4c2c --- /dev/null +++ b/backend/src/routes/adminArchives.js @@ -0,0 +1,411 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const archiver = require('archiver'); +const AdmZip = require('adm-zip'); +const router = express.Router(); + +// Get all archived events +router.get('/', adminAuth, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 20; + const offset = (page - 1) * limit; + + // Get total count + const totalCount = await db('events') + .where('is_archived', formatBoolean(true)) + .count('id as count') + .first(); + + // Get archived events + const archives = await db('events') + .select( + 'events.*', + db.raw('COUNT(DISTINCT photos.id) as photo_count'), + db.raw('SUM(photos.size_bytes) as total_size') + ) + .leftJoin('photos', 'events.id', 'photos.event_id') + .where('events.is_archived', formatBoolean(true)) + .groupBy('events.id') + .orderBy('events.archived_at', 'desc') + .limit(limit) + .offset(offset); + + // Check if archive files exist and get their sizes + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const archivesWithFileInfo = await Promise.all(archives.map(async (archive) => { + let archiveFileSize = 0; + if (archive.archive_path) { + try { + const fullArchivePath = path.join(storagePath, archive.archive_path); + const stats = await fs.stat(fullArchivePath); + archiveFileSize = stats.size; + } catch (error) { + console.error(`Archive file not found: ${archive.archive_path}`); + } + } + + return { + id: archive.id, + slug: archive.slug, + eventName: archive.event_name, + eventDate: archive.event_date, + eventType: archive.event_type, + hostEmail: archive.host_email, + archivedAt: archive.archived_at ? new Date(archive.archived_at).toISOString() : null, + expiresAt: archive.expires_at ? new Date(archive.expires_at).toISOString() : null, + photoCount: archive.photo_count || 0, + originalSize: archive.total_size || 0, + archiveSize: archiveFileSize, + archivePath: archive.archive_path + }; + })); + + res.json({ + archives: archivesWithFileInfo, + pagination: { + page, + limit, + total: totalCount.count, + totalPages: Math.ceil(totalCount.count / limit) + } + }); + } catch (error) { + console.error('Archives list error:', error); + res.status(500).json({ error: 'Failed to fetch archives' }); + } +}); + +// Get single archive details +router.get('/:id', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', formatBoolean(true)) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + // Get photo details + const photos = await db('photos') + .where('event_id', archive.id) + .select('filename', 'type', 'size_bytes', 'uploaded_at'); + + // Check archive file + let archiveFileInfo = null; + if (archive.archive_path) { + try { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + const stats = await fs.stat(fullArchivePath); + archiveFileInfo = { + size: stats.size, + createdAt: stats.birthtime, + path: archive.archive_path + }; + } catch (error) { + console.error('Archive file not found:', error); + } + } + + res.json({ + id: archive.id, + slug: archive.slug, + eventName: archive.event_name, + eventDate: archive.event_date, + eventType: archive.event_type, + hostEmail: archive.host_email, + adminEmail: archive.admin_email, + welcomeMessage: archive.welcome_message, + colorTheme: archive.color_theme, + createdAt: archive.created_at, + expiresAt: archive.expires_at, + archivedAt: archive.archived_at, + photos: photos, + archiveFile: archiveFileInfo + }); + } catch (error) { + console.error('Archive details error:', error); + res.status(500).json({ error: 'Failed to fetch archive details' }); + } +}); + +// Restore archive +router.post('/:id/restore', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', formatBoolean(true)) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + // Check if archive file exists + if (!archive.archive_path) { + return res.status(400).json({ error: 'No archive file found' }); + } + + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + + try { + await fs.access(fullArchivePath); + } catch (error) { + return res.status(404).json({ error: 'Archive file not found on disk' }); + } + + // Extract the archive + try { + const zip = new AdmZip(fullArchivePath); + const eventsDir = path.join(storagePath, 'events/active'); + const eventDir = path.join(eventsDir, archive.slug); + + // Create event directory if it doesn't exist + await fs.mkdir(eventDir, { recursive: true }); + + // Log ZIP contents for debugging + console.log(`Extracting archive to: ${eventDir}`); + const entries = zip.getEntries(); + console.log(`Archive contains ${entries.length} entries`); + + // Extract files to the event directory + zip.extractAllTo(eventDir, true); + + // Get list of extracted files to update database + const extractedPhotos = []; + + // First, collect all category information from the ZIP structure + const categoriesMap = new Map(); + + for (const entry of entries) { + if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) { + const filename = path.basename(entry.entryName); + const dirPath = path.dirname(entry.entryName); + const actualFilePath = path.join(eventDir, entry.entryName); + + try { + // Check if file was extracted successfully + const stats = await fs.stat(actualFilePath); + + // Determine category from directory structure + let categoryId = null; + if (dirPath && dirPath !== '.') { + // Get the first level directory as category + const categoryName = dirPath.split(path.sep)[0]; + + if (!categoriesMap.has(categoryName)) { + // Check if this category exists in the database + const existingCategory = await db('photo_categories') + .where('event_id', archive.id) + .where('name', categoryName) + .first(); + + if (existingCategory) { + categoriesMap.set(categoryName, existingCategory.id); + } else { + // Create the category if it doesn't exist + const insertResult = await db('photo_categories').insert({ + event_id: archive.id, + name: categoryName, + slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'), + created_at: new Date() + }).returning('id'); + + const newCategoryId = insertResult[0]?.id || insertResult[0]; + categoriesMap.set(categoryName, newCategoryId); + } + } + + categoryId = categoriesMap.get(categoryName); + } + + // Check if photo already exists in database + const existingPhoto = await db('photos') + .where('event_id', archive.id) + .where('filename', filename) + .first(); + + if (!existingPhoto) { + // Store relative path from storage root + const relativePath = path.relative(storagePath, actualFilePath); + extractedPhotos.push({ + event_id: archive.id, + filename: filename, + original_filename: filename, + path: relativePath, + thumbnail_path: null, // Will be regenerated by thumbnail service + type: path.extname(filename).substring(1).toLowerCase(), + size_bytes: stats.size, + category_id: categoryId, + uploaded_at: new Date() + }); + } + } catch (statError) { + console.error(`Failed to stat file: ${actualFilePath}`); + console.error(`Entry name was: ${entry.entryName}`); + console.error('Error:', statError.message); + // Skip this file if we can't stat it + continue; + } + } + } + + // Insert new photos if any + if (extractedPhotos.length > 0) { + await db('photos').insert(extractedPhotos); + } + + } catch (extractError) { + console.error('Archive extraction error:', extractError); + return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message }); + } + + // Update event status + const thirtyDaysFromNow = new Date(); + thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30); + + await db('events') + .where('id', req.params.id) + .update({ + is_archived: false, + is_active: true, + archive_path: null, + archived_at: null, + expires_at: thirtyDaysFromNow.toISOString() // Reset expiration - works on both DBs + }); + + // Log activity + await db('activity_logs').insert({ + activity_type: 'archive_restored', + actor_type: 'admin', + actor_id: req.admin.id, + actor_name: req.admin.username, + event_id: archive.id, + metadata: JSON.stringify({ event_name: archive.event_name }) + }); + + res.json({ message: 'Archive restored successfully' }); + } catch (error) { + console.error('Archive restore error:', error); + res.status(500).json({ error: 'Failed to restore archive' }); + } +}); + +// Download archive +router.get('/:id/download', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', formatBoolean(true)) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + if (!archive.archive_path) { + return res.status(404).json({ error: 'Archive file not found' }); + } + + // Check if file exists + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + + try { + await fs.access(fullArchivePath); + } catch (error) { + return res.status(404).json({ error: 'Archive file not found on disk' }); + } + + // Set headers for download + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${archive.slug}.zip"`); + + // Stream the file + const fileStream = require('fs').createReadStream(fullArchivePath); + fileStream.pipe(res); + + // Log download + await db('activity_logs').insert({ + activity_type: 'archive_downloaded', + actor_type: 'admin', + actor_id: req.admin.id, + actor_name: req.admin.username, + event_id: archive.id, + metadata: JSON.stringify({ event_name: archive.event_name }) + }); + } catch (error) { + console.error('Archive download error:', error); + res.status(500).json({ error: 'Failed to download archive' }); + } +}); + +// Delete archive permanently +router.delete('/:id', adminAuth, async (req, res) => { + try { + const archive = await db('events') + .where('id', req.params.id) + .where('is_archived', formatBoolean(true)) + .first(); + + if (!archive) { + return res.status(404).json({ error: 'Archive not found' }); + } + + // Delete archive file if exists + if (archive.archive_path) { + try { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + await fs.unlink(fullArchivePath); + } catch (error) { + console.error('Failed to delete archive file:', error); + } + } + + // Delete thumbnails for this event + const photos = await db('photos').where('event_id', req.params.id).select('thumbnail_path'); + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + + for (const photo of photos) { + if (photo.thumbnail_path) { + try { + const thumbPath = path.join(storagePath, photo.thumbnail_path.replace(/^\//, '')); + await fs.unlink(thumbPath); + } catch (error) { + // Ignore errors - thumbnail might already be deleted + } + } + } + + // Delete from database (cascade will delete photos and logs) + await db('events').where('id', req.params.id).delete(); + + // Log activity + await db('activity_logs').insert({ + activity_type: 'archive_deleted', + actor_type: 'admin', + actor_id: req.admin.id, + actor_name: req.admin.username, + metadata: JSON.stringify({ + event_name: archive.event_name, + archived_date: archive.archived_at + }) + }); + + res.json({ message: 'Archive deleted permanently' }); + } catch (error) { + console.error('Archive delete error:', error); + res.status(500).json({ error: 'Failed to delete archive' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js new file mode 100644 index 0000000..15813f5 --- /dev/null +++ b/backend/src/routes/adminAuth.js @@ -0,0 +1,99 @@ +const express = require('express'); +const bcrypt = require('bcrypt'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { endSession } = require('../middleware/sessionTimeout'); +const { validatePasswordStrength } = require('../utils/passwordGenerator'); +const router = express.Router(); + +// Change password +router.post('/change-password', [ + adminAuth, + body('currentPassword').notEmpty().withMessage('Current password is required'), + body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { currentPassword, newPassword } = req.body; + const userId = req.admin.id; // Changed from req.user.id to req.admin.id + + // Validate new password strength + const passwordValidation = validatePasswordStrength(newPassword); + if (!passwordValidation.isValid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.messages + }); + } + + // Get user from database + const user = await db('admin_users') + .where('id', userId) + .first(); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Verify current password + const validPassword = await bcrypt.compare(currentPassword, user.password_hash); + if (!validPassword) { + return res.status(400).json({ error: 'Current password is incorrect' }); + } + + // Hash new password with more rounds + const newPasswordHash = await bcrypt.hash(newPassword, 12); + + // Update password and clear must_change_password flag + await db('admin_users') + .where('id', userId) + .update({ + password_hash: newPasswordHash, + must_change_password: false, + updated_at: new Date() + }); + + // Log activity + await logActivity('password_changed', + { admin_id: userId }, + null, + { type: 'admin', id: userId, name: user.username } + ); + + res.json({ message: 'Password changed successfully' }); + } catch (error) { + console.error('Password change error:', error); + res.status(500).json({ error: 'Failed to change password' }); + } +}); + +// Logout +router.post('/logout', adminAuth, async (req, res) => { + try { + // Get token from header + const token = req.headers.authorization?.split(' ')[1]; + if (token) { + // End the session + endSession(token); + } + + // Log activity + await logActivity('admin_logout', + { admin_id: req.admin.id }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Logged out successfully' }); + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ error: 'Failed to logout' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js new file mode 100644 index 0000000..758f15e --- /dev/null +++ b/backend/src/routes/adminCMS.js @@ -0,0 +1,83 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const router = express.Router(); + +// Get all CMS pages +router.get('/pages', adminAuth, async (req, res) => { + try { + const pages = await db('cms_pages').select('*').orderBy('slug', 'asc'); + res.json(pages); + } catch (error) { + console.error('Error fetching CMS pages:', error); + res.status(500).json({ error: 'Failed to fetch pages' }); + } +}); + +// Get a single CMS page +router.get('/pages/:slug', adminAuth, async (req, res) => { + try { + const { slug } = req.params; + const page = await db('cms_pages').where('slug', slug).first(); + + if (!page) { + return res.status(404).json({ error: 'Page not found' }); + } + + res.json(page); + } catch (error) { + console.error('Error fetching CMS page:', error); + res.status(500).json({ error: 'Failed to fetch page' }); + } +}); + +// Update a CMS page +router.put('/pages/:slug', adminAuth, [ + body('title_en').optional().isString(), + body('title_de').optional().isString(), + body('content_en').optional().isString(), + body('content_de').optional().isString() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slug } = req.params; + const { title_en, title_de, content_en, content_de } = req.body; + + const page = await db('cms_pages').where('slug', slug).first(); + if (!page) { + return res.status(404).json({ error: 'Page not found' }); + } + + // Update the page + await db('cms_pages') + .where('slug', slug) + .update({ + title_en, + title_de, + content_en, + content_de, + updated_at: new Date() + }); + + const updated = await db('cms_pages').where('slug', slug).first(); + + // Log activity + await logActivity('cms_page_updated', + { page: slug }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(updated); + } catch (error) { + console.error('Error updating CMS page:', error); + res.status(500).json({ error: 'Failed to update page' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js new file mode 100644 index 0000000..f3d9000 --- /dev/null +++ b/backend/src/routes/adminCategories.js @@ -0,0 +1,185 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const router = express.Router(); + +// Get all global categories +router.get('/global', adminAuth, async (req, res) => { + try { + const categories = await db('photo_categories') + .where('is_global', formatBoolean(true)) + .orderBy('name', 'asc'); + + res.json(categories); + } catch (error) { + console.error('Error fetching categories:', error); + res.status(500).json({ error: 'Failed to fetch categories' }); + } +}); + +// Get categories for a specific event (global + event-specific) +router.get('/event/:eventId', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + + const categories = await db('photo_categories') + .where(function() { + this.where('is_global', formatBoolean(true)) + .orWhere('event_id', eventId); + }) + .orderBy('is_global', 'desc') + .orderBy('name', 'asc'); + + res.json(categories); + } catch (error) { + console.error('Error fetching event categories:', error); + res.status(500).json({ error: 'Failed to fetch categories' }); + } +}); + +// Create a new category +router.post('/', adminAuth, [ + body('name').notEmpty().withMessage('Category name is required'), + body('slug').optional(), + body('is_global').optional().isBoolean(), + body('event_id').optional().isInt() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { name, slug, is_global = true, event_id = null } = req.body; + + // Generate slug if not provided + const categorySlug = slug || name.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); + + // Check if slug already exists for this scope + const existing = await db('photo_categories') + .where('slug', categorySlug) + .where(function() { + if (is_global) { + this.where('is_global', formatBoolean(true)); + } else { + this.where('event_id', event_id); + } + }) + .first(); + + if (existing) { + return res.status(400).json({ error: 'Category with this slug already exists' }); + } + + // Create category + const insertResult = await db('photo_categories').insert({ + name, + slug: categorySlug, + is_global, + event_id: is_global ? null : event_id + }).returning('id'); + + const categoryId = insertResult[0]?.id || insertResult[0]; + + const category = await db('photo_categories').where('id', categoryId).first(); + + // Log activity + await logActivity('category_created', + { categoryName: name, isGlobal: is_global }, + event_id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(category); + } catch (error) { + console.error('Error creating category:', error); + res.status(500).json({ error: 'Failed to create category' }); + } +}); + +// Update a category +router.put('/:id', adminAuth, [ + body('name').notEmpty().withMessage('Category name is required') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const { name } = req.body; + + const category = await db('photo_categories').where('id', id).first(); + if (!category) { + return res.status(404).json({ error: 'Category not found' }); + } + + await db('photo_categories') + .where('id', id) + .update({ + name, + slug: name.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim() + }); + + const updated = await db('photo_categories').where('id', id).first(); + + // Log activity + await logActivity('category_updated', + { categoryName: name }, + category.event_id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(updated); + } catch (error) { + console.error('Error updating category:', error); + res.status(500).json({ error: 'Failed to update category' }); + } +}); + +// Delete a category +router.delete('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const category = await db('photo_categories').where('id', id).first(); + if (!category) { + return res.status(404).json({ error: 'Category not found' }); + } + + // Check if category has photos + const photoCount = await db('photos').where('category_id', id).count('id as count').first(); + if (photoCount.count > 0) { + return res.status(400).json({ + error: 'Cannot delete category with photos. Please reassign photos first.' + }); + } + + await db('photo_categories').where('id', id).delete(); + + // Log activity + await logActivity('category_deleted', + { categoryName: category.name }, + category.event_id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Category deleted successfully' }); + } catch (error) { + console.error('Error deleting category:', error); + res.status(500).json({ error: 'Failed to delete category' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js new file mode 100644 index 0000000..3817acf --- /dev/null +++ b/backend/src/routes/adminDashboard.js @@ -0,0 +1,323 @@ +const express = require('express'); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); +const { formatBoolean } = require('../utils/dbCompat'); +const router = express.Router(); + +// Get dashboard statistics +router.get('/stats', adminAuth, async (req, res) => { + try { + // Get active events count + const activeEvents = await db('events') + .where('is_active', formatBoolean(true)) + .where('is_archived', formatBoolean(false)) + .count('id as count') + .first(); + + // Get events expiring within 7 days + const sevenDaysFromNow = new Date(); + sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); + const now = new Date(); + + const expiringEvents = await db('events') + .where('is_active', formatBoolean(true)) + .where('is_archived', formatBoolean(false)) + .where('expires_at', '<=', sevenDaysFromNow.toISOString()) + .where('expires_at', '>', now.toISOString()) + .count('id as count') + .first(); + + // Get total photos count + const totalPhotos = await db('photos') + .count('id as count') + .first(); + + // Get storage usage (sum of all photo sizes) + const storageUsed = await db('photos') + .sum('size_bytes as total') + .first(); + + // Get total views (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const totalViews = await db('access_logs') + .where('action', 'view') + .where('timestamp', '>=', thirtyDaysAgo.toISOString()) + .count('id as count') + .first(); + + // Get total downloads (last 30 days) + const totalDownloads = await db('access_logs') + .where('action', 'download') + .where('timestamp', '>=', thirtyDaysAgo.toISOString()) + .count('id as count') + .first(); + + // Get archived events count + const archivedEvents = await db('events') + .where('is_archived', formatBoolean(true)) + .count('id as count') + .first(); + + // Calculate trends (compare with previous 30 days) + const sixtyDaysAgo = new Date(); + sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60); + + const previousViews = await db('access_logs') + .where('action', 'view') + .where('timestamp', '>=', sixtyDaysAgo.toISOString()) + .where('timestamp', '<', thirtyDaysAgo.toISOString()) + .count('id as count') + .first(); + + const previousDownloads = await db('access_logs') + .where('action', 'download') + .where('timestamp', '>=', sixtyDaysAgo.toISOString()) + .where('timestamp', '<', thirtyDaysAgo.toISOString()) + .count('id as count') + .first(); + + // Calculate trend percentages + const viewsTrend = previousViews.count > 0 + ? ((totalViews.count - previousViews.count) / previousViews.count) * 100 + : 0; + + const downloadsTrend = previousDownloads.count > 0 + ? ((totalDownloads.count - previousDownloads.count) / previousDownloads.count) * 100 + : 0; + + res.json({ + activeEvents: activeEvents.count || 0, + expiringEvents: expiringEvents.count || 0, + totalPhotos: totalPhotos.count || 0, + storageUsed: storageUsed.total || 0, + totalViews: totalViews.count || 0, + totalDownloads: totalDownloads.count || 0, + viewsTrend: Math.round(viewsTrend * 10) / 10, + downloadsTrend: Math.round(downloadsTrend * 10) / 10, + archivedEvents: archivedEvents.count || 0 + }); + } catch (error) { + console.error('Dashboard stats error:', error); + res.status(500).json({ error: 'Failed to fetch dashboard statistics' }); + } +}); + +// Get recent activity +router.get('/activity', adminAuth, async (req, res) => { + try { + const limit = parseInt(req.query.limit) || 10; + + const activities = await db('activity_logs') + .select('activity_logs.*', 'events.event_name') + .leftJoin('events', 'activity_logs.event_id', 'events.id') + .orderBy('activity_logs.created_at', 'desc') + .limit(limit); + + // Format activities + const formattedActivities = activities.map(activity => ({ + id: activity.id, + type: activity.activity_type, + actorType: activity.actor_type, + actorName: activity.actor_name, + eventName: activity.event_name, + metadata: (() => { + try { + if (!activity.metadata) return {}; + if (typeof activity.metadata === 'object') return activity.metadata; + return JSON.parse(activity.metadata); + } catch (e) { + console.warn('Failed to parse metadata for activity:', activity.id, e.message); + return {}; + } + })(), + createdAt: activity.created_at + })); + + res.json(formattedActivities); + } catch (error) { + console.error('Activity log error:', error); + res.status(500).json({ error: 'Failed to fetch activity log' }); + } +}); + +// Get system health status +router.get('/health', adminAuth, async (req, res) => { + try { + const os = require('os'); + + // Check database connectivity + let dbStatus = 'healthy'; + try { + await db.raw('SELECT 1'); + } catch (error) { + dbStatus = 'error'; + } + + // Check email queue + const [pendingEmails] = await db('email_queue') + .where('status', 'pending') + .count('* as count'); + + const twentyFourHoursAgo = new Date(); + twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24); + + const [failedEmails] = await db('email_queue') + .where('status', 'failed') + .where('created_at', '>=', twentyFourHoursAgo.toISOString()) + .count('* as count'); + + const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy'; + + // Check disk space (simplified) + const storageStatus = 'healthy'; // In production, check actual disk usage + + // Memory usage + const memoryUsage = { + total: os.totalmem(), + free: os.freemem(), + used: os.totalmem() - os.freemem(), + percentage: Math.round(((os.totalmem() - os.freemem()) / os.totalmem()) * 100) + }; + + const memoryStatus = memoryUsage.percentage > 90 ? 'warning' : 'healthy'; + + // Overall health + const statuses = [dbStatus, emailStatus, storageStatus, memoryStatus]; + let overallHealth = 'healthy'; + if (statuses.includes('error')) overallHealth = 'error'; + else if (statuses.includes('warning')) overallHealth = 'warning'; + + res.json({ + overall: overallHealth, + services: { + database: dbStatus, + email: emailStatus, + storage: storageStatus, + memory: memoryStatus + }, + details: { + emailQueue: { + pending: pendingEmails.count, + failed: failedEmails.count + }, + memory: memoryUsage + } + }); + } catch (error) { + console.error('Health check error:', error); + res.status(500).json({ + overall: 'error', + error: 'Failed to check system health' + }); + } +}); + +// Get analytics data for charts +router.get('/analytics', adminAuth, async (req, res) => { + try { + const days = sanitizeDays(req.query.days || 7); + + // Generate date range + const dates = []; + for (let i = days - 1; i >= 0; i--) { + dates.push({ + date: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + views: 0, + downloads: 0, + uniqueVisitors: 0 + }); + } + + // Calculate the start date for queries + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + const startDateStr = startDate.toISOString(); + + // Get views per day + const viewsData = await db('access_logs') + .select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count')) + .where('action', 'view') + .where('timestamp', '>=', startDateStr) + .groupByRaw('DATE(timestamp)'); + + // Get downloads per day + const downloadsData = await db('access_logs') + .select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count')) + .where('action', 'download') + .where('timestamp', '>=', startDateStr) + .groupByRaw('DATE(timestamp)'); + + // Get unique visitors per day + const visitorsData = await db('access_logs') + .select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(DISTINCT ip_address) as count')) + .where('timestamp', '>=', startDateStr) + .groupByRaw('DATE(timestamp)'); + + // Merge data into dates array + viewsData.forEach(row => { + const dateObj = dates.find(d => d.date === row.date); + if (dateObj) dateObj.views = row.count; + }); + + downloadsData.forEach(row => { + const dateObj = dates.find(d => d.date === row.date); + if (dateObj) dateObj.downloads = row.count; + }); + + visitorsData.forEach(row => { + const dateObj = dates.find(d => d.date === row.date); + if (dateObj) dateObj.uniqueVisitors = row.count; + }); + + // Get top galleries by views + const topGalleries = await db('access_logs') + .select('events.event_name', 'events.slug') + .select(db.raw('COUNT(*) as views')) + .join('events', 'access_logs.event_id', 'events.id') + .where('access_logs.action', 'view') + .where('access_logs.timestamp', '>=', startDateStr) + .groupBy('events.id') + .orderBy('views', 'desc') + .limit(5); + + // Get device breakdown (simplified - based on user agent) + const deviceData = await db('access_logs') + .select( + db.raw(` + CASE + WHEN user_agent LIKE '%Mobile%' THEN 'mobile' + WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet' + ELSE 'desktop' + END as device_type + `), + db.raw('COUNT(*) as count') + ) + .where('timestamp', '>=', startDateStr) + .groupBy('device_type'); + + const totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0); + const devices = { + desktop: 0, + mobile: 0, + tablet: 0 + }; + + deviceData.forEach(d => { + devices[d.device_type] = Math.round((d.count / totalDevices) * 100); + }); + + res.json({ + chartData: dates, + topGalleries, + devices + }); + } catch (error) { + console.error('Analytics error:', error); + res.status(500).json({ error: 'Failed to fetch analytics data' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js new file mode 100644 index 0000000..45d17be --- /dev/null +++ b/backend/src/routes/adminEmail.js @@ -0,0 +1,432 @@ +const express = require('express'); +const nodemailer = require('nodemailer'); +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const router = express.Router(); + +// Get email configuration +router.get('/config', adminAuth, async (req, res) => { + try { + const config = await db('email_configs').first(); + + if (!config) { + return res.json({ + smtp_host: '', + smtp_port: 587, + smtp_secure: false, + smtp_user: '', + smtp_pass: '', // Don't send actual password + from_email: '', + from_name: '' + }); + } + + // Don't send the actual password + res.json({ + ...config, + smtp_pass: config.smtp_pass ? '********' : '' + }); + } catch (error) { + console.error('Email config fetch error:', error); + res.status(500).json({ error: 'Failed to fetch email configuration' }); + } +}); + +// Update email configuration +router.post('/config', [ + adminAuth, + body('smtp_host').notEmpty().withMessage('SMTP host is required'), + body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'), + body('from_email').isEmail().withMessage('Invalid from email address') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + smtp_host, + smtp_port, + smtp_secure, + smtp_user, + smtp_pass, + from_email, + from_name + } = req.body; + + // Check if config exists + const existingConfig = await db('email_configs').first(); + + const configData = { + smtp_host, + smtp_port: parseInt(smtp_port), + smtp_secure: smtp_secure || false, + smtp_user: smtp_user || '', + from_email, + from_name: from_name || 'Photo Sharing', + updated_at: new Date() + }; + + // Only update password if provided and not masked + if (smtp_pass && smtp_pass !== '********') { + configData.smtp_pass = smtp_pass; + } + + if (existingConfig) { + await db('email_configs') + .where('id', existingConfig.id) + .update(configData); + } else { + await db('email_configs').insert(configData); + } + + // Log activity + await logActivity('email_config_updated', + { smtp_host, from_email }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Email configuration updated successfully' }); + } catch (error) { + console.error('Email config update error:', error); + res.status(500).json({ error: 'Failed to update email configuration' }); + } +}); + +// Test email configuration +router.post('/test', adminAuth, async (req, res) => { + try { + const { test_email } = req.body; + + if (!test_email) { + return res.status(400).json({ error: 'Test email address is required' }); + } + + // Get email config + const config = await db('email_configs').first(); + + if (!config) { + return res.status(400).json({ error: 'Email configuration not found. Please configure SMTP settings first.' }); + } + + // Validate SMTP configuration + if (!config.smtp_host || !config.smtp_port) { + return res.status(400).json({ + error: 'Incomplete email configuration', + details: 'SMTP host and port are required' + }); + } + + // Check if password might be masked (this shouldn't happen when fetching from DB) + if (config.smtp_pass === '********') { + return res.status(400).json({ + error: 'Invalid email configuration', + details: 'SMTP password appears to be masked. Please reconfigure your email settings.' + }); + } + + // Create transporter with detailed logging + const transportConfig = { + host: config.smtp_host, + port: parseInt(config.smtp_port), + secure: config.smtp_secure === true || config.smtp_secure === 1, + auth: config.smtp_user && config.smtp_pass ? { + user: config.smtp_user, + pass: config.smtp_pass + } : undefined, + logger: process.env.NODE_ENV === 'development', + debug: process.env.NODE_ENV === 'development' + }; + + console.log('Creating email transporter with config:', { + host: transportConfig.host, + port: transportConfig.port, + secure: transportConfig.secure, + auth: transportConfig.auth ? 'configured' : 'none' + }); + + const transporter = nodemailer.createTransport(transportConfig); + + // Send test email + await transporter.sendMail({ + from: `${config.from_name} <${config.from_email}>`, + to: test_email, + subject: 'Test Email - Photo Sharing Platform', + html: ` +

    Test Email Successful!

    +

    This is a test email from your Photo Sharing platform.

    +

    If you're seeing this, your email configuration is working correctly.

    +
    +

    + Sent from: ${config.from_email}
    + SMTP Host: ${config.smtp_host}
    + Time: ${new Date().toISOString()} +

    + `, + text: 'Test Email Successful! Your email configuration is working correctly.' + }); + + res.json({ message: 'Test email sent successfully' }); + } catch (error) { + console.error('Test email error:', error); + console.error('Error stack:', error.stack); + + // Provide more specific error messages + let errorMessage = 'Failed to send test email'; + let details = error.message; + + if (error.code === 'ECONNREFUSED') { + errorMessage = 'Failed to connect to SMTP server'; + details = 'Please check your SMTP host and port settings'; + } else if (error.code === 'EAUTH') { + errorMessage = 'SMTP authentication failed'; + details = 'Please check your SMTP username and password'; + } else if (error.code === 'ESOCKET') { + errorMessage = 'Network error'; + details = 'Could not establish connection to SMTP server'; + } + + res.status(500).json({ + error: errorMessage, + details: details, + code: error.code + }); + } +}); + +// Get email templates +router.get('/templates', adminAuth, async (req, res) => { + try { + const templates = await db('email_templates') + .select('*') + .orderBy('template_key'); + + // Parse variables JSON and format for multi-language support + const formattedTemplates = templates.map(template => { + const result = { + id: template.id, + template_key: template.template_key, + variables: (() => { + try { + if (!template.variables) return []; + if (typeof template.variables === 'object') return template.variables; + return JSON.parse(template.variables); + } catch (e) { + console.warn('Failed to parse variables for template:', template.template_key, e.message); + return []; + } + })(), + updated_at: template.updated_at + }; + + // Handle both old and new schema formats + if (template.subject_en !== undefined) { + // New schema with language columns + result.subject_en = template.subject_en; + result.body_html_en = template.body_html_en; + result.body_text_en = template.body_text_en; + result.subject_de = template.subject_de; + result.body_html_de = template.body_html_de; + result.body_text_de = template.body_text_de; + } else { + // Old schema - use basic columns for both languages + result.subject_en = template.subject; + result.body_html_en = template.body_html; + result.body_text_en = template.body_text; + result.subject_de = template.subject; + result.body_html_de = template.body_html; + result.body_text_de = template.body_text; + } + + return result; + }); + + res.json(formattedTemplates); + } catch (error) { + console.error('Email templates fetch error:', error); + res.status(500).json({ error: 'Failed to fetch email templates' }); + } +}); + +// Get single template +router.get('/templates/:key', adminAuth, async (req, res) => { + try { + const template = await db('email_templates') + .where('template_key', req.params.key) + .first(); + + if (!template) { + return res.status(404).json({ error: 'Template not found' }); + } + + // Handle both old and new schema formats + const response = { + id: template.id, + template_key: template.template_key, + variables: (() => { + try { + if (!template.variables) return []; + if (typeof template.variables === 'object') return template.variables; + return JSON.parse(template.variables); + } catch (e) { + console.warn('Failed to parse variables for template:', template.template_key, e.message); + return []; + } + })(), + updated_at: template.updated_at + }; + + // Check which columns exist and use them appropriately + if (template.subject_en !== undefined) { + // New schema with language columns + response.subject_en = template.subject_en; + response.body_html_en = template.body_html_en; + response.body_text_en = template.body_text_en; + response.subject_de = template.subject_de; + response.body_html_de = template.body_html_de; + response.body_text_de = template.body_text_de; + } else { + // Old schema - use basic columns for both languages + response.subject_en = template.subject; + response.body_html_en = template.body_html; + response.body_text_en = template.body_text; + response.subject_de = template.subject; + response.body_html_de = template.body_html; + response.body_text_de = template.body_text; + } + + res.json(response); + } catch (error) { + console.error('Email template fetch error:', error); + res.status(500).json({ error: 'Failed to fetch email template' }); + } +}); + +// Update email template +router.put('/templates/:key', [ + adminAuth, + body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'), + body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'), + body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'), + body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + subject_en, subject_de, + body_html_en, body_html_de, + body_text_en, body_text_de + } = req.body; + + const updateData = { + updated_at: new Date() + }; + + // Check which columns exist in the database + const template = await db('email_templates') + .where('template_key', req.params.key) + .first(); + + if (!template) { + return res.status(404).json({ error: 'Template not found' }); + } + + // Determine schema type and update accordingly + if (template.subject_en !== undefined) { + // New schema with language columns + if (subject_en !== undefined) updateData.subject_en = subject_en; + if (subject_de !== undefined) updateData.subject_de = subject_de; + if (body_html_en !== undefined) updateData.body_html_en = body_html_en; + if (body_html_de !== undefined) updateData.body_html_de = body_html_de; + if (body_text_en !== undefined) updateData.body_text_en = body_text_en || ''; + if (body_text_de !== undefined) updateData.body_text_de = body_text_de || ''; + + // Also update basic columns if they exist + if (template.subject !== undefined) { + updateData.subject = subject_en || updateData.subject_en; + updateData.body_html = body_html_en || updateData.body_html_en; + updateData.body_text = body_text_en || updateData.body_text_en || ''; + } + } else { + // Old schema - only update basic columns + if (subject_en !== undefined) { + updateData.subject = subject_en; + updateData.body_html = body_html_en; + updateData.body_text = body_text_en || ''; + } + } + + const updated = await db('email_templates') + .where('template_key', req.params.key) + .update(updateData); + + if (!updated) { + return res.status(404).json({ error: 'Template not found' }); + } + + // Log activity + await logActivity('email_template_updated', + { template_key: req.params.key }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Email template updated successfully' }); + } catch (error) { + console.error('Email template update error:', error); + res.status(500).json({ error: 'Failed to update email template' }); + } +}); + +// Preview email template +router.post('/templates/:key/preview', adminAuth, async (req, res) => { + try { + const template = await db('email_templates') + .where('template_key', req.params.key) + .first(); + + if (!template) { + return res.status(404).json({ error: 'Template not found' }); + } + + const { preview_data, language = 'en' } = req.body; + + // Get the appropriate language version + const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en'; + const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en'; + const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en'; + + // Handle backward compatibility + let htmlContent = template[htmlField] || template.body_html || ''; + let textContent = template[textField] || template.body_text || ''; + let subject = template[subjectField] || template.subject || ''; + + if (preview_data) { + Object.keys(preview_data).forEach(key => { + const regex = new RegExp(`{{${key}}}`, 'g'); + htmlContent = htmlContent.replace(regex, preview_data[key]); + textContent = textContent.replace(regex, preview_data[key]); + subject = subject.replace(regex, preview_data[key]); + }); + } + + res.json({ + subject, + body_html: htmlContent, + body_text: textContent, + language + }); + } catch (error) { + console.error('Email template preview error:', error); + res.status(500).json({ error: 'Failed to preview email template' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminEvents-enhanced.js b/backend/src/routes/adminEvents-enhanced.js new file mode 100644 index 0000000..740dd16 --- /dev/null +++ b/backend/src/routes/adminEvents-enhanced.js @@ -0,0 +1,124 @@ +// This is a partial file showing the enhanced event creation with password validation +// Only the relevant parts are shown - merge with existing adminEvents.js + +const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); + +// Enhanced event creation with password validation +router.post('/', adminAuth, [ + body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), + body('event_name').notEmpty().trim(), + body('event_date').isDate(), + body('host_email').isEmail().normalizeEmail(), + body('admin_email').isEmail().normalizeEmail(), + body('password').notEmpty(), // Remove the weak isLength validation + body('expiration_days').isInt({ min: 1, max: 365 }).optional(), + body('welcome_message').optional().trim(), + body('color_theme').optional().trim(), + body('allow_user_uploads').optional().isBoolean().toBoolean(), + body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), + body('host_name').notEmpty().trim() +], async (req, res) => { + try { + console.log('Create event request body:', req.body); + const errors = validationResult(req); + if (!errors.isEmpty()) { + console.error('Validation errors:', errors.array()); + return res.status(400).json({ errors: errors.array() }); + } + + const { + event_type, + event_name, + event_date, + host_name, + host_email, + admin_email, + password, + welcome_message = '', + color_theme = null, + expiration_days = 30, + allow_user_uploads = false, + upload_category_id = null + } = req.body; + + // Validate password strength for gallery + const passwordValidation = validatePasswordInContext(password, 'gallery', { + eventName: event_name + }); + + if (!passwordValidation.valid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + + // Generate unique slug + const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`; + let slug = baseSlug; + let counter = 1; + + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + // Generate share link + const shareToken = crypto.randomBytes(16).toString('hex'); + const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; + + // Hash password with configurable rounds + const password_hash = await bcrypt.hash(password, getBcryptRounds()); + + // Calculate expiration date (days after event date) + const expires_at = new Date(event_date); + expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); + + // Create folder structure + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + + // Insert into database + const insertResult = await db('events').insert({ + slug, + event_type, + event_name, + event_date, + host_name, + host_email, + admin_email, + password_hash, + welcome_message, + color_theme, + share_link: shareLink, + expires_at: expires_at.toISOString(), + created_at: new Date().toISOString(), + allow_user_uploads, + upload_category_id + }).returning('id'); + + // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) + const eventId = insertResult[0]?.id || insertResult[0]; + + // Log activity + await logActivity('event_created', + { + event_type, + expires_at, + password_strength: passwordValidation.score + }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Rest of the implementation remains the same... + // Queue creation email, etc. + } catch (error) { + console.error('Error creating event:', error); + res.status(500).json({ error: 'Failed to create event' }); + } +}); \ No newline at end of file diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js new file mode 100644 index 0000000..4894c60 --- /dev/null +++ b/backend/src/routes/adminEvents.js @@ -0,0 +1,758 @@ +const express = require('express'); +const { body, query, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const router = express.Router(); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const fs = require('fs').promises; +const path = require('path'); +const { archiveEvent } = require('../services/archiveService'); +const { queueEmail } = require('../services/emailProcessor'); +const { escapeLikePattern } = require('../utils/sqlSecurity'); +// formatDate import removed - dates are formatted by email processor +const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); +const { formatBoolean } = require('../utils/dbCompat'); + +// Create new event +router.post('/', adminAuth, [ + body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), + body('event_name').notEmpty().trim(), + body('event_date').isDate(), + body('host_email').isEmail().normalizeEmail(), + body('admin_email').isEmail().normalizeEmail(), + body('password').isLength({ min: 6 }), + body('expiration_days').isInt({ min: 1, max: 365 }).optional(), + body('welcome_message').optional().trim(), + body('color_theme').optional().trim(), + body('allow_user_uploads').optional().isBoolean().toBoolean(), + body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), + body('host_name').notEmpty().trim() +], async (req, res) => { + try { + console.log('Create event request body:', req.body); + const errors = validationResult(req); + if (!errors.isEmpty()) { + console.error('Validation errors:', errors.array()); + return res.status(400).json({ errors: errors.array() }); + } + + const { + event_type, + event_name, + event_date, + host_name, + host_email, + admin_email, + password, + welcome_message = '', + color_theme = null, + expiration_days = 30, + allow_user_uploads = false, + upload_category_id = null + } = req.body; + + // Validate password strength + const passwordValidation = validatePasswordInContext(password, 'gallery', { + eventName: event_name + }); + + if (!passwordValidation.valid) { + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + + // Generate unique slug + const processedEventName = event_name + .toLowerCase() + .replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash + .replace(/-+/g, '-') // Replace multiple dashes with single dash + .replace(/^-|-$/g, ''); // Remove leading/trailing dashes + const baseSlug = `${event_type}-${processedEventName}-${event_date}`; + let slug = baseSlug; + let counter = 1; + + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + // Generate share link + const shareToken = crypto.randomBytes(16).toString('hex'); + const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; + + // Hash password with configurable rounds + const password_hash = await bcrypt.hash(password, getBcryptRounds()); + + // Calculate expiration date (days after event date) + // Parse YYYY-MM-DD format as local date to avoid timezone issues + let expires_at; + if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) { + const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10)); + expires_at = new Date(year, month - 1, day); + } else { + expires_at = new Date(event_date); + } + expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); + + // Create folder structure + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + + // Insert into database + const insertResult = await db('events').insert({ + slug, + event_type, + event_name, + event_date, + host_name, + host_email, + admin_email, + password_hash, + welcome_message, + color_theme, + share_link: shareLink, + expires_at: expires_at.toISOString(), + created_at: new Date().toISOString(), + allow_user_uploads, + upload_category_id + }).returning('id'); + + // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) + const eventId = insertResult[0]?.id || insertResult[0]; + + // Log activity + await logActivity('event_created', + { event_type, expires_at }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Queue creation email + // Language detection is handled by email processor + + await db('email_queue').insert({ + event_id: eventId, + recipient_email: host_email, + email_type: 'gallery_created', + email_data: JSON.stringify({ + host_name: host_name, + event_name, + event_date: event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareLink, + gallery_password: password, + expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor + welcome_message: welcome_message || '' + }), + status: 'pending', + created_at: new Date() + // scheduled_at will use default value + }); + + res.json({ + id: eventId, + slug, + event_name, + event_type, + share_link: shareLink, + expires_at: expires_at.toISOString(), + created_at: new Date().toISOString() + }); + } catch (error) { + console.error('Error creating event:', error); + res.status(500).json({ error: 'Failed to create event' }); + } +}); + +// Get all events with pagination and filters +router.get('/', adminAuth, async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 20; + const offset = (page - 1) * limit; + const search = req.query.search || ''; + const status = req.query.status || 'all'; + const sortBy = req.query.sortBy || 'created_at'; + const sortOrder = req.query.sortOrder || 'desc'; + + // Build query + let query = db('events'); + + // Apply search filter + if (search) { + const escapedSearch = escapeLikePattern(search); + query = query.where((builder) => { + builder.where('event_name', 'like', `%${escapedSearch}%`) + .orWhere('admin_email', 'like', `%${escapedSearch}%`) + .orWhere('slug', 'like', `%${escapedSearch}%`); + }); + } + + // Apply status filter + if (status === 'active') { + query = query.where('is_active', formatBoolean(true)).where('is_archived', formatBoolean(false)); + } else if (status === 'archived') { + query = query.where('is_archived', formatBoolean(true)); + } else if (status === 'inactive') { + query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false)); + } else if (status === 'expiring') { + const sevenDaysFromNow = new Date(); + sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7); + query = query + .where('is_active', formatBoolean(true)) + .where('is_archived', formatBoolean(false)) + .where('expires_at', '<=', sevenDaysFromNow.toISOString()) + .where('expires_at', '>', new Date().toISOString()); + } + + // Get total count for pagination + const countQuery = query.clone(); + const [{ count }] = await countQuery.count('* as count'); + + // Apply sorting and pagination + const events = await query + .orderBy(sortBy, sortOrder) + .limit(limit) + .offset(offset); + + // Get photo counts for each event + const eventIds = events.map(e => e.id); + const photoCounts = await db('photos') + .whereIn('event_id', eventIds) + .groupBy('event_id') + .select('event_id') + .count('* as count'); + + // Map photo counts to events + const photoCountMap = photoCounts.reduce((acc, { event_id, count }) => { + acc[event_id] = parseInt(count); + return acc; + }, {}); + + // Add photo counts to events and convert dates + const eventsWithCounts = events.map(event => ({ + ...event, + photo_count: photoCountMap[event.id] || 0, + // Convert Unix timestamps to ISO strings + created_at: event.created_at ? new Date(event.created_at).toISOString() : null, + expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null, + archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null + })); + + res.json({ + events: eventsWithCounts, + pagination: { + page, + limit, + total: parseInt(count), + totalPages: Math.ceil(count / limit) + } + }); + } catch (error) { + console.error('Error fetching events:', error); + res.status(500).json({ error: 'Failed to fetch events' }); + } +}); + +// Get single event details +router.get('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const event = await db('events') + .where('id', id) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Get photo count + const [{ count: photoCount }] = await db('photos') + .where('event_id', id) + .count('* as count'); + + // Get total size + const [{ totalSize }] = await db('photos') + .where('event_id', id) + .sum('size_bytes as totalSize'); + + // Get recent photos + const recentPhotos = await db('photos') + .where('event_id', id) + .orderBy('uploaded_at', 'desc') + .limit(10) + .select('filename', 'type', 'size_bytes', 'uploaded_at'); + + // Get view and download statistics + const [{ totalViews }] = await db('access_logs') + .where('event_id', id) + .where('action', 'view') + .count('* as totalViews'); + + const [{ totalDownloads }] = await db('access_logs') + .where('event_id', id) + .where('action', 'download') + .count('* as totalDownloads'); + + const [{ uniqueVisitors }] = await db('access_logs') + .where('event_id', id) + .countDistinct('ip_address as uniqueVisitors'); + + res.json({ + ...event, + photo_count: parseInt(photoCount) || 0, + total_size: parseInt(totalSize) || 0, + total_views: parseInt(totalViews) || 0, + total_downloads: parseInt(totalDownloads) || 0, + unique_visitors: parseInt(uniqueVisitors) || 0, + recent_photos: recentPhotos + }); + } catch (error) { + console.error('Error fetching event:', error); + res.status(500).json({ error: 'Failed to fetch event details' }); + } +}); + +// Update event +router.put('/:id', adminAuth, [ + body('event_name').optional().trim().notEmpty(), + body('admin_email').optional().isEmail(), + body('is_active').optional().isBoolean(), + body('expires_at').optional().isISO8601(), + body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), + body('color_theme').optional({ nullable: true }), + body('allow_user_uploads').optional().isBoolean(), + body('host_name').optional().trim().notEmpty(), + body('upload_category_id').optional().custom((value) => { + // Accept null, undefined, or integer values + if (value === null || value === undefined) return true; + return Number.isInteger(Number(value)); + }).withMessage('upload_category_id must be an integer or null'), + body('hero_photo_id').optional().custom((value) => { + // Accept null, undefined, or numeric values + if (value === null || value === undefined) return true; + // Check if it's a number or can be converted to a valid integer + const num = Number(value); + return !isNaN(num) && Number.isInteger(num); + }).withMessage('hero_photo_id must be an integer or null') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2)); + console.log('Request body:', req.body); + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const updates = req.body; + + // Log the update request for debugging + console.log('Update event request:', { + id, + updates, + color_theme_length: updates.color_theme ? updates.color_theme.length : 0, + color_theme_type: typeof updates.color_theme, + hero_photo_id: updates.hero_photo_id, + hero_photo_id_type: typeof updates.hero_photo_id + }); + + // Check if event exists + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Update event + await db('events') + .where('id', id) + .update(updates); + + // Log activity + await logActivity('event_updated', + { changes: Object.keys(updates), eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event updated successfully' }); + } catch (error) { + console.error('Error updating event:', error); + res.status(500).json({ error: 'Failed to update event' }); + } +}); + +// Delete event +router.delete('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + // Check if event exists + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Start a transaction to ensure all deletions succeed or fail together + await db.transaction(async (trx) => { + // 1. Delete activity logs (audit trail) + await trx('activity_logs').where('event_id', id).del(); + + // 2. Delete access logs + await trx('access_logs').where('event_id', id).del(); + + // 3. Delete email queue entries + await trx('email_queue').where('event_id', id).del(); + + // 4. Delete photos (this will also handle hero_photo_id foreign key) + await trx('photos').where('event_id', id).del(); + + // 5. Delete categories (photo_categories has CASCADE delete for event_id) + await trx('photo_categories').where('event_id', id).del(); + + // 6. Finally delete the event + await trx('events').where('id', id).del(); + + // Delete event folder from storage if it exists + if (event.folder_path) { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path); + + try { + const fsPromises = require('fs').promises; + await fsPromises.rm(eventFolderPath, { recursive: true, force: true }); + } catch (err) { + console.error('Failed to delete event folder:', err); + // Don't fail the transaction if folder deletion fails + } + } + + // Delete archive if exists + if (event.archive_path) { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const archivePath = path.join(storagePath, event.archive_path); + + try { + const fsPromises = require('fs').promises; + await fsPromises.unlink(archivePath); + } catch (err) { + console.error('Failed to delete archive file:', err); + // Don't fail the transaction if file deletion fails + } + } + }); + + // Log activity (outside transaction) + await logActivity('event_deleted', + { event_name: event.event_name }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event deleted successfully' }); + } catch (error) { + console.error('Error deleting event:', error); + + // Provide more specific error messages + if (error.message && error.message.includes('foreign key constraint')) { + res.status(500).json({ + error: 'Cannot delete event due to existing references. Please contact support.', + details: error.message + }); + } else { + res.status(500).json({ + error: 'Failed to delete event', + details: process.env.NODE_ENV === 'development' ? error.message : undefined + }); + } + } +}); + +// Toggle event status +router.post('/:id/toggle-status', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const newStatus = !event.is_active; + await db('events') + .where('id', id) + .update({ + is_active: newStatus, + updated_at: new Date() + }); + + // Log activity + await logActivity(newStatus ? 'event_activated' : 'event_deactivated', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Event ${newStatus ? 'activated' : 'deactivated'} successfully`, + is_active: newStatus + }); + } catch (error) { + console.error('Error toggling event status:', error); + res.status(500).json({ error: 'Failed to toggle event status' }); + } +}); + +// Reset event password +router.post('/:id/reset-password', adminAuth, async (req, res) => { + try { + const { id } = req.params; + const { sendEmail = true } = req.body; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (event.is_archived) { + return res.status(400).json({ error: 'Cannot reset password for archived event' }); + } + + // Generate new password + const { generatePassword } = require('../utils/passwordGenerator'); + const newPassword = generatePassword(); + const passwordHash = await bcrypt.hash(newPassword, 10); + + // Update event with new password + await db('events') + .where('id', id) + .update({ + password_hash: passwordHash, + updated_at: new Date() + }); + + // Log activity + await logActivity('password_reset', + { eventName: event.event_name, emailSent: sendEmail }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Queue email notification if requested + if (sendEmail) { + // For password reset, we'll need to create a template or use a different approach + // For now, let's use the gallery_created template with updated password + await queueEmail(id, event.host_email, 'gallery_created', { + host_name: event.host_email.split('@')[0], + event_name: event.event_name, + event_date: event.event_date, // Pass raw date - will be formatted by email processor + gallery_link: event.share_link, + gallery_password: newPassword, + expiry_date: event.expires_at // Pass raw date - will be formatted by email processor + }); + } + + res.json({ + message: 'Password reset successfully', + newPassword: newPassword, + emailSent: sendEmail + }); + } catch (error) { + console.error('Error resetting password:', error); + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + +// Resend creation email +router.post('/:id/resend-email', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + // Get event details + const event = await db('events') + .where('id', id) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // The email processor will determine the language based on: + // 1. Event language setting + // 2. App settings general_default_language + // 3. Email config default language + // 4. Domain-based detection + // So we don't need to determine it here + + // For resending creation email, we need the actual password + // First, try to get it from the request body if provided + let galleryPassword = req.body.password; + + // If no password provided, we can't decrypt the existing one + // So we'll show a security message + if (!galleryPassword) { + // We'll let the email processor determine the language for the security message + galleryPassword = '{{password_security_message}}'; + } + + // Dates will be formatted by the email processor based on recipient language + + // Queue the email + await queueEmail(id, event.host_email, 'gallery_created', { + host_name: event.host_name || event.host_email.split('@')[0], + event_name: event.event_name, + event_date: event.event_date, // Pass raw date - will be formatted by email processor + gallery_link: event.share_link, + gallery_password: galleryPassword, + expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor + welcome_message: event.welcome_message || '', + eventId: id, + isResend: true // Flag to indicate this is a resend + }); + + // Log the activity using the proper schema + try { + await logActivity('email_resent', { + email_type: 'gallery_created', + recipient: event.host_email, + ip_address: req.ip || '0.0.0.0', + user_agent: req.get('user-agent') || 'Unknown' + }, id, { + type: 'admin', + id: req.admin.id, + name: req.admin.username + }); + } catch (logError) { + console.error('Warning: Failed to log activity:', logError); + // Don't fail the request if activity logging fails + } + + res.json({ + success: true, + message: 'Creation email has been queued for sending' + }); + } catch (error) { + console.error('Error resending creation email:', error); + console.error('Stack trace:', error.stack); + res.status(500).json({ error: 'Failed to resend creation email' }); + } +}); + +// Archive event +router.post('/:id/archive', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (event.is_archived) { + return res.status(400).json({ error: 'Event is already archived' }); + } + + // Use the archive service to create ZIP archive + await archiveEvent(event); + + // Log activity + await logActivity('event_archived', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event archived successfully' }); + } catch (error) { + console.error('Error archiving event:', error); + res.status(500).json({ error: 'Failed to archive event' }); + } +}); + +// Bulk archive events +router.post('/bulk-archive', adminAuth, [ + body('eventIds').isArray().withMessage('eventIds must be an array'), + body('eventIds.*').isInt().withMessage('Each eventId must be an integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { eventIds } = req.body; + + if (eventIds.length === 0) { + return res.status(400).json({ error: 'No events selected for archiving' }); + } + + // Get all events to archive + const events = await db('events') + .whereIn('id', eventIds) + .where('is_archived', formatBoolean(false)); + + if (events.length === 0) { + return res.status(400).json({ error: 'No valid events found to archive' }); + } + + const results = { + successful: [], + failed: [] + }; + + // Process each event + for (const event of events) { + try { + // Use the archive service to create ZIP archive + await archiveEvent(event); + + // Log activity + await logActivity('event_archived', + { eventName: event.event_name, bulkOperation: true }, + event.id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + results.successful.push({ + id: event.id, + name: event.event_name + }); + } catch (error) { + console.error(`Failed to archive event ${event.id}:`, error); + results.failed.push({ + id: event.id, + name: event.event_name, + error: error.message + }); + } + } + + // Log bulk archive activity + await logActivity('bulk_archive_completed', + { + totalEvents: eventIds.length, + successfulCount: results.successful.length, + failedCount: results.failed.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`, + results + }); + } catch (error) { + console.error('Error in bulk archive:', error); + res.status(500).json({ error: 'Failed to perform bulk archive' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js new file mode 100644 index 0000000..865117b --- /dev/null +++ b/backend/src/routes/adminNotifications.js @@ -0,0 +1,122 @@ +const express = require('express'); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const router = express.Router(); + +// Get notifications (unread activity logs) +router.get('/', adminAuth, async (req, res) => { + try { + const { limit = 20, includeRead = false } = req.query; + + let query = db('activity_logs') + .select( + 'activity_logs.*', + 'events.event_name' + ) + .leftJoin('events', 'activity_logs.event_id', 'events.id') + .orderBy('activity_logs.created_at', 'desc') + .limit(parseInt(limit)); + + // By default, only show unread notifications + if (includeRead !== 'true') { + query = query.whereNull('activity_logs.read_at'); + } + + const notifications = await query; + + // Format notifications + const formattedNotifications = notifications.map(notification => ({ + id: notification.id, + type: notification.activity_type, + actorType: notification.actor_type, + actorName: notification.actor_name, + eventName: notification.event_name, + eventId: notification.event_id, + metadata: (() => { + try { + if (!notification.metadata) return {}; + if (typeof notification.metadata === 'object') return notification.metadata; + return JSON.parse(notification.metadata); + } catch (e) { + console.warn('Failed to parse metadata for notification:', notification.id, e.message); + return {}; + } + })(), + createdAt: notification.created_at, + readAt: notification.read_at, + isRead: !!notification.read_at + })); + + // Get unread count + const unreadCount = await db('activity_logs') + .whereNull('read_at') + .count('id as count') + .first(); + + res.json({ + notifications: formattedNotifications, + unreadCount: unreadCount.count || 0 + }); + } catch (error) { + console.error('Notifications fetch error:', error); + res.status(500).json({ error: 'Failed to fetch notifications' }); + } +}); + +// Mark notification as read +router.put('/:id/read', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + await db('activity_logs') + .where('id', id) + .update({ + read_at: new Date() + }); + + res.json({ message: 'Notification marked as read' }); + } catch (error) { + console.error('Mark notification read error:', error); + res.status(500).json({ error: 'Failed to mark notification as read' }); + } +}); + +// Mark all notifications as read +router.put('/read-all', adminAuth, async (req, res) => { + try { + await db('activity_logs') + .whereNull('read_at') + .update({ + read_at: new Date() + }); + + res.json({ message: 'All notifications marked as read' }); + } catch (error) { + console.error('Mark all notifications read error:', error); + res.status(500).json({ error: 'Failed to mark all notifications as read' }); + } +}); + +// Delete old notifications (older than 30 days and read) +router.delete('/clear-old', adminAuth, async (req, res) => { + try { + // Use database-agnostic date calculation + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const deletedCount = await db('activity_logs') + .whereNotNull('read_at') + .where('created_at', '<', thirtyDaysAgo) + .delete(); + + res.json({ + message: 'Old notifications cleared', + deletedCount + }); + } catch (error) { + console.error('Clear old notifications error:', error); + res.status(500).json({ error: 'Failed to clear old notifications' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js new file mode 100644 index 0000000..4dcb953 --- /dev/null +++ b/backend/src/routes/adminPhotos.js @@ -0,0 +1,795 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs').promises; +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor'); +const { generatePhotoFilename } = require('../utils/filenameSanitizer'); +const { escapeLikePattern } = require('../utils/sqlSecurity'); +const { validateUploadedFiles } = require('../middleware/uploadValidation'); +const router = express.Router(); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Configure multer for file uploads +// IMPORTANT: Using synchronous functions to prevent file corruption +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + console.log('Multer destination called for file:', file.originalname); + const { eventId } = req.params; + + // We'll validate the event exists in the route handler + // For now, just create a temp destination + const tempPath = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`); + + // Create directory synchronously + require('fs').mkdirSync(tempPath, { recursive: true }); + console.log('Temp destination path:', tempPath); + + // Store temp path for cleanup + req.tempUploadPath = tempPath; + + cb(null, tempPath); + }, + filename: (req, file, cb) => { + console.log('Multer filename called for file:', file.originalname); + // Use a simple temporary filename + const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`; + console.log('Temp filename:', tempName); + cb(null, tempName); + } +}); + +const { validateFileType } = require('../utils/fileSecurityUtils'); + +const upload = multer({ + storage: storage, + limits: { + fileSize: 50 * 1024 * 1024, // 50MB limit per file + files: 500, // Maximum 500 files + // Set a reasonable field size limit to prevent memory issues + fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields + // Add part size limits to prevent incomplete uploads + parts: 10000, // Maximum number of parts (fields + files) + headerPairs: 2000 // Maximum number of header key-value pairs + }, + fileFilter: (req, file, cb) => { + // Accept images only with proper validation + const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; + + if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { + return cb(null, true); + } else { + cb(new Error('Only JPEG, PNG and WebP images are allowed')); + } + }, + // Add abort on limit to stop processing when limits are exceeded + abortOnLimit: true +}); + +const { createFileUploadValidator } = require('../utils/fileSecurityUtils'); + +// Create content validator middleware +const validateUploadContent = createFileUploadValidator({ + allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + maxFileSize: 50 * 1024 * 1024, + validateContent: true +}); + +// Request timeout middleware for uploads +const uploadTimeout = (timeout = 300000) => { // 5 minutes default + return (req, res, next) => { + // Set timeout for the request + req.setTimeout(timeout, () => { + console.error('Upload request timed out'); + if (!res.headersSent) { + res.status(408).json({ error: 'Upload request timed out' }); + } + }); + + // Set response timeout as well + res.setTimeout(timeout, () => { + console.error('Upload response timed out'); + }); + + next(); + }; +}; + +// Upload photos for an event +// Increased limit to 500 files, but recommend chunked uploads for better performance +router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout + upload.array('photos', 500)(req, res, (err) => { + if (err) { + console.error('Multer error:', err); + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' }); + } + if (err.code === 'LIMIT_FILE_COUNT') { + return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' }); + } + return res.status(400).json({ error: `Upload error: ${err.message}` }); + } + return res.status(400).json({ error: err.message || 'Upload failed' }); + } + next(); + }); +}, validateUploadContent, validateUploadedFiles, async (req, res) => { + try { + const { eventId } = req.params; + const { category_id } = req.body; + + console.log('Upload request received for event:', eventId); + console.log('Body:', req.body); + console.log('Files:', req.files ? req.files.length : 'none'); + console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype }))); + console.log('Category ID received:', category_id); + + // Verify event exists and admin has access + const event = await db('events').where({ id: eventId }).first(); + if (!event) { + console.error('Event not found:', eventId); + // Clean up temp files + if (req.tempUploadPath) { + try { + await fs.rm(req.tempUploadPath, { recursive: true, force: true }); + } catch (e) { + console.error('Failed to clean up temp path:', e); + } + } + return res.status(404).json({ error: 'Event not found' }); + } + + if (!req.files || req.files.length === 0) { + console.error('No files in request. req.files:', req.files); + console.error('Request body keys:', Object.keys(req.body)); + // Clean up temp files + if (req.tempUploadPath) { + try { + await fs.rm(req.tempUploadPath, { recursive: true, force: true }); + } catch (e) { + console.error('Failed to clean up temp path:', e); + } + } + return res.status(400).json({ error: 'No files uploaded' }); + } + + // Parse category_id to number if provided + const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; + + // Get category details if provided + let category = null; + if (parsedCategoryId) { + category = await db('photo_categories').where({ id: parsedCategoryId }).first(); + if (!category) { + // Clean up temp files + if (req.tempUploadPath) { + try { + await fs.rm(req.tempUploadPath, { recursive: true, force: true }); + } catch (e) { + console.error('Failed to clean up temp path:', e); + } + } + return res.status(400).json({ error: 'Invalid category' }); + } + } + + // Create final destination directory + const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug); + await fs.mkdir(finalDestPath, { recursive: true }); + + const uploadedPhotos = []; + const errors = []; + + // Process files in batches to optimize database operations + const BATCH_SIZE = 25; // Increased batch size for better performance with large uploads + + for (let i = 0; i < req.files.length; i += BATCH_SIZE) { + const batch = req.files.slice(i, i + BATCH_SIZE); + + // Start a single transaction for the batch + const trx = await db.transaction(); + + try { + // Get initial counter for this batch + let batchCounter = 1; + if (category) { + const categoryData = await trx('photo_categories') + .where({ id: parsedCategoryId }) + .forUpdate() + .first(); + batchCounter = (categoryData.photo_counter || 0) + 1; + } else { + const uncategorizedCount = await trx('photos') + .where({ event_id: eventId }) + .whereNull('category_id') + .count('id as count') + .first(); + batchCounter = (parseInt(uncategorizedCount.count) || 0) + 1; + } + + const batchPhotos = []; + const fileRenameOperations = []; // Store rename operations to do after commit + + // First pass: prepare data and move files from temp to final location + for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) { + const file = batch[fileIndex]; + const counter = batchCounter + fileIndex; + const tempPath = file.path; // Original temp path + + try { + // Verify file is complete before processing + const tempStats = await fs.stat(tempPath); + if (tempStats.size === 0) { + throw new Error('File is empty - upload may have been interrupted'); + } + + // Generate new filename + const extension = path.extname(file.originalname); + const newFilename = generatePhotoFilename( + event.event_name, + category ? category.name : 'uncategorized', + counter, + extension + ); + + // Calculate final path + const finalPath = path.join(finalDestPath, newFilename); + const storagePath = getStoragePath(); + const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath); + + // Prepare photo data for batch insert + const photoData = { + event_id: parseInt(eventId), + filename: newFilename, + path: relativePath, + thumbnail_path: null, // Will generate after successful commit + category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null, + type: 'individual', + size_bytes: tempStats.size // Use actual file size from stat + }; + + batchPhotos.push(photoData); + + // Store move operation for later + fileRenameOperations.push({ + tempPath: tempPath, + finalPath: finalPath, + filename: newFilename, + photoData: photoData + }); + } catch (error) { + console.error(`Error preparing file ${file.originalname}:`, error); + errors.push({ filename: file.originalname, error: error.message }); + } + } + + // Insert all photos in this batch + if (batchPhotos.length > 0) { + console.log(`Inserting batch of ${batchPhotos.length} photos with category_id: ${parsedCategoryId}`); + + const insertedIds = await trx('photos').insert(batchPhotos).returning('id'); + + // Update category counter if needed + if (category && parsedCategoryId) { + const newCounter = batchCounter + batchPhotos.length - 1; + await trx('photo_categories') + .where({ id: parsedCategoryId }) + .update({ photo_counter: newCounter }); + console.log(`Updated category ${parsedCategoryId} counter to ${newCounter}`); + } + + // Commit the transaction first + await trx.commit(); + console.log(`Successfully committed batch of ${batchPhotos.length} photos`); + + // Now move files from temp to final location after successful commit + for (let idx = 0; idx < fileRenameOperations.length; idx++) { + const operation = fileRenameOperations[idx]; + try { + // Move the file from temp to final location + await fs.rename(operation.tempPath, operation.finalPath); + console.log(`Moved file from ${operation.tempPath} to ${operation.finalPath}`); + + // Verify the file was moved successfully + const finalStats = await fs.stat(operation.finalPath); + if (finalStats.size !== operation.photoData.size_bytes) { + throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`); + } + + // Generate thumbnail with final path + let thumbnailPath = null; + try { + thumbnailPath = await generateThumbnail(operation.finalPath); + + // Update the database with thumbnail path + if (thumbnailPath && insertedIds[idx]) { + const photoId = insertedIds[idx]?.id || insertedIds[idx]; + await db('photos') + .where({ id: photoId }) + .update({ thumbnail_path: thumbnailPath }); + } + } catch (thumbError) { + console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message); + } + + // Add to successful uploads + uploadedPhotos.push({ + id: insertedIds[idx]?.id || insertedIds[idx], + filename: operation.filename, + size: operation.photoData.size_bytes, + category_id: operation.photoData.category_id + }); + } catch (moveError) { + console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError); + errors.push({ + filename: operation.filename, + error: `File move failed: ${moveError.message}` + }); + + // Try to clean up the database entry if file move failed + if (insertedIds[idx]) { + const photoId = insertedIds[idx]?.id || insertedIds[idx]; + try { + await db('photos').where({ id: photoId }).delete(); + console.log(`Cleaned up database entry for failed photo ${photoId}`); + } catch (cleanupError) { + console.error(`Failed to clean up database entry:`, cleanupError); + } + } + } + } + } else { + // No photos to insert, just rollback + await trx.rollback(); + } + } catch (error) { + console.error(`Error processing batch starting at index ${i}:`, error); + console.error('Stack trace:', error.stack); + + // Rollback if not already committed + if (!trx.isCompleted()) { + await trx.rollback(); + } + + // Add all files in this batch to errors + for (const file of batch) { + errors.push({ + filename: file.originalname, + error: `Batch processing failed: ${error.message}` + }); + } + } + } + + // Clean up temp upload directory + if (req.tempUploadPath) { + try { + await fs.rm(req.tempUploadPath, { recursive: true, force: true }); + console.log(`Cleaned up temp upload directory: ${req.tempUploadPath}`); + } catch (e) { + console.error('Failed to clean up temp upload directory:', e); + } + } + + // Log activity + await logActivity('photos_uploaded', + { count: uploadedPhotos.length, eventName: event.event_name }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + // Include any files that were invalid from the validation middleware + const totalInvalidFiles = (req.invalidFiles || []).concat(errors); + + // Prepare response + const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0); + const response = { + message: `Successfully uploaded ${uploadedPhotos.length} photos`, + photos: uploadedPhotos, + totalFiles: totalAttempted, + successCount: uploadedPhotos.length, + failureCount: totalInvalidFiles.length + }; + + // Include error details if any files failed + if (totalInvalidFiles.length > 0) { + response.errors = totalInvalidFiles; + response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`; + } + + res.json(response); + } catch (error) { + console.error('Error uploading photos:', error); + + // Clean up temp upload directory on error + if (req.tempUploadPath) { + try { + await fs.rm(req.tempUploadPath, { recursive: true, force: true }); + console.log(`Cleaned up temp upload directory after error: ${req.tempUploadPath}`); + } catch (e) { + console.error('Failed to clean up temp upload directory:', e); + } + } + + res.status(500).json({ error: 'Failed to upload photos' }); + } +}); + +// Delete a photo +router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + // Get photo details + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Delete physical files + const storagePath = getStoragePath(); + const photoPath = path.join(storagePath, 'events/active', photo.path); + + try { + await fs.unlink(photoPath); + } catch (error) { + console.error('Error deleting photo file:', error); + } + + // Delete thumbnail if exists + if (photo.thumbnail_path) { + const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path); + try { + await fs.unlink(thumbPath); + } catch (error) { + console.error('Error deleting thumbnail:', error); + } + } + + // Remove from database + await db('photos').where({ id: photoId }).delete(); + + // Log activity + const event = await db('events').where({ id: eventId }).first(); + await logActivity('photo_deleted', + { filename: photo.filename, eventName: event.event_name }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Photo deleted successfully' }); + } catch (error) { + console.error('Error deleting photo:', error); + res.status(500).json({ error: 'Failed to delete photo' }); + } +}); + +// Update a photo (e.g., change category) +router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + const { category_id } = req.body; + + // Verify photo belongs to event + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Update photo + await db('photos') + .where({ id: photoId }) + .update({ category_id: category_id || null }); + + res.json({ message: 'Photo updated successfully' }); + } catch (error) { + console.error('Error updating photo:', error); + res.status(500).json({ error: 'Failed to update photo' }); + } +}); + +// Bulk delete photos +router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + const { photoIds } = req.body; + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return res.status(400).json({ error: 'Invalid photo IDs' }); + } + + // Get all photos to delete + const photos = await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId); + + if (photos.length === 0) { + return res.status(404).json({ error: 'No photos found' }); + } + + // Delete physical files + const storagePath = getStoragePath(); + const event = await db('events').where({ id: eventId }).first(); + + for (const photo of photos) { + // Delete photo file + const photoPath = path.join(storagePath, 'events/active', photo.path); + try { + await fs.unlink(photoPath); + } catch (error) { + console.error('Error deleting photo file:', error); + } + + // Delete thumbnail + if (photo.thumbnail_path) { + const thumbPath = path.join(storagePath, photo.thumbnail_path); + try { + await fs.unlink(thumbPath); + } catch (error) { + console.error('Error deleting thumbnail:', error); + } + } + } + + // Delete from database + await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId) + .delete(); + + // Log activity + await logActivity('photos_bulk_deleted', + { count: photos.length, eventName: event.event_name }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: `${photos.length} photos deleted successfully` }); + } catch (error) { + console.error('Error bulk deleting photos:', error); + res.status(500).json({ error: 'Failed to delete photos' }); + } +}); + +// Bulk update photos +router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + const { photoIds, updates } = req.body; + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return res.status(400).json({ error: 'Invalid photo IDs' }); + } + + // Verify all photos belong to the event + const photoCount = await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId) + .count('id as count') + .first(); + + if (photoCount.count !== photoIds.length) { + return res.status(400).json({ error: 'Some photos do not belong to this event' }); + } + + // Update photos + const updateData = {}; + if (updates.category_id !== undefined) { + updateData.category_id = updates.category_id || null; + } + + await db('photos') + .whereIn('id', photoIds) + .where('event_id', eventId) + .update(updateData); + + res.json({ message: `${photoIds.length} photos updated successfully` }); + } catch (error) { + console.error('Error bulk updating photos:', error); + res.status(500).json({ error: 'Failed to update photos' }); + } +}); + +// Download a photo +router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + const storagePath = getStoragePath(); + const filePath = path.join(storagePath, 'events/active', photo.path); + + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + return res.status(404).json({ error: 'Photo file not found' }); + } + + // Send file + res.download(filePath, photo.filename); + } catch (error) { + console.error('Error downloading photo:', error); + res.status(500).json({ error: 'Failed to download photo' }); + } +}); + +// Get all photos for an event +router.get('/:eventId/photos', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; + + let query = db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where({ 'photos.event_id': eventId }) + .select( + 'photos.*', + 'photo_categories.name as category_name', + 'photo_categories.slug as category_slug' + ); + + // Filter by category (including uncategorized) + if (category_id !== undefined) { + if (category_id === '' || category_id === '0') { + query = query.whereNull('photos.category_id'); + } else { + query = query.where({ 'photos.category_id': category_id }); + } + } + + // Keep type filter for backwards compatibility + if (type) { + query = query.where({ 'photos.type': type }); + } + + // Search by filename + if (search) { + const escapedSearch = escapeLikePattern(search); + query = query.where('photos.filename', 'like', `%${escapedSearch}%`); + } + + // Sorting + let orderByColumn = 'photos.uploaded_at'; + if (sort === 'name') { + orderByColumn = 'photos.filename'; + } else if (sort === 'size') { + orderByColumn = 'photos.size_bytes'; + } + + const photos = await query.orderBy(orderByColumn, order); + + res.json({ + photos: photos.map(photo => ({ + id: photo.id, + filename: photo.filename, + url: `/admin/events/${eventId}/photo/${photo.id}`, + thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null, + type: photo.type, + category_id: photo.category_id, + category_name: photo.category_name, + category_slug: photo.category_slug, + size: photo.size_bytes, + uploaded_at: photo.uploaded_at + })) + }); + } catch (error) { + console.error('Error fetching photos:', error); + res.status(500).json({ error: 'Failed to fetch photos' }); + } +}); + +// Serve photo with admin authentication +router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + const storagePath = getStoragePath(); + const filePath = path.join(storagePath, 'events/active', photo.path); + + // Check if file exists + try { + await fs.access(filePath); + } catch (error) { + return res.status(404).json({ error: 'Photo file not found' }); + } + + // Set appropriate headers + res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`); + res.setHeader('Cache-Control', 'private, max-age=3600'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + + // Send file (sendFile requires absolute path) + res.sendFile(path.resolve(filePath)); + } catch (error) { + console.error('Error serving photo:', error); + res.status(500).json({ error: 'Failed to serve photo' }); + } +}); + +// Serve thumbnail with admin authentication +router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { + try { + const { eventId, photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: eventId }) + .first(); + + if (!photo) { + console.error(`Photo not found: ${photoId}, event ${eventId}`); + return res.status(404).json({ error: 'Photo not found' }); + } + + // Ensure thumbnail exists and is valid, regenerate if needed + const thumbnailPath = await ensureThumbnail(photo); + + if (!thumbnailPath) { + console.error(`Failed to generate thumbnail for photo ${photoId}`); + return res.status(404).json({ error: 'Thumbnail generation failed' }); + } + + const storagePath = getStoragePath(); + const filePath = path.join(storagePath, thumbnailPath); + + // Set appropriate headers + res.setHeader('Content-Type', 'image/jpeg'); // Thumbnails are always JPEG + res.setHeader('Cache-Control', 'private, max-age=3600'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + + // Send file (sendFile requires absolute path) + res.sendFile(path.resolve(filePath)); + } catch (error) { + console.error('Error serving thumbnail:', error); + console.error('Photo ID:', req.params.photoId); + console.error('Event ID:', req.params.eventId); + res.status(500).json({ error: 'Failed to serve thumbnail' }); + } +}); + +// Debug endpoint to check photo existence +router.get('/:eventId/debug', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + + const event = await db('events').where({ id: eventId }).first(); + const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first(); + const photos = await db('photos').where({ event_id: eventId }).limit(5); + + res.json({ + event: event || 'Not found', + photoCount: photoCount.count, + samplePhotos: photos, + storagePath: getStoragePath() + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js new file mode 100644 index 0000000..746283e --- /dev/null +++ b/backend/src/routes/adminSettings.js @@ -0,0 +1,650 @@ +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs').promises; +const { body, validationResult } = require('express-validator'); +const { db, logActivity } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { adminAuth } = require('../middleware/auth'); +const { clearMaintenanceCache } = require('../middleware/maintenance'); +const { clearSettingsCache } = require('../services/rateLimitService'); +const router = express.Router(); + +// Configure multer for logo uploads +const storage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(__dirname, '../../storage/uploads/logos'); + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `logo-${Date.now()}${ext}`); + } +}); + +const { validateFileType } = require('../utils/fileSecurityUtils'); + +const upload = multer({ + storage, + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB + fileFilter: (req, file, cb) => { + // Note: SVG files are excluded from magic number validation for logos + const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']; + + if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { + return cb(null, true); + } else { + cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed')); + } + } +}); + +// Configure multer for favicon uploads +const faviconStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(__dirname, '../../storage/uploads/favicons'); + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `favicon-${Date.now()}${ext}`); + } +}); + +const faviconUpload = multer({ + storage: faviconStorage, + limits: { fileSize: 1 * 1024 * 1024 }, // 1MB + fileFilter: (req, file, cb) => { + const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon']; + + // For ICO files, we can't use the standard validateFileType + if (file.mimetype === 'image/png') { + if (validateFileType(file.originalname, file.mimetype, ['image/png'])) { + cb(null, true); + } else { + cb(new Error('Invalid PNG file')); + } + } else if (allowedMimeTypes.includes(file.mimetype) && + (file.originalname.toLowerCase().endsWith('.ico') || + file.originalname.toLowerCase().endsWith('.png'))) { + cb(null, true); + } else { + cb(new Error('Favicon must be PNG or ICO format')); + } + } +}); + +// Get all settings +router.get('/', adminAuth, async (req, res) => { + try { + const settings = await db('app_settings').select('*'); + + // Convert to object format + const settingsObject = {}; + settings.forEach(setting => { + if (setting.setting_value) { + try { + // Try to parse as JSON first + settingsObject[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + settingsObject[setting.setting_key] = setting.setting_value; + } + } else { + settingsObject[setting.setting_key] = null; + } + }); + + res.json(settingsObject); + } catch (error) { + console.error('Settings fetch error:', error); + res.status(500).json({ error: 'Failed to fetch settings' }); + } +}); + +// Get settings by type +router.get('/:type', adminAuth, async (req, res) => { + try { + const { type } = req.params; + const settings = await db('app_settings') + .where('setting_type', type) + .select('*'); + + // Convert to object format + const settingsObject = {}; + settings.forEach(setting => { + if (setting.setting_value) { + try { + // Try to parse as JSON first + settingsObject[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + settingsObject[setting.setting_key] = setting.setting_value; + } + } else { + settingsObject[setting.setting_key] = null; + } + }); + + res.json(settingsObject); + } catch (error) { + console.error('Settings fetch error:', error); + res.status(500).json({ error: 'Failed to fetch settings' }); + } +}); + +// Update branding settings +router.put('/branding', adminAuth, async (req, res) => { + try { + const { + company_name, + company_tagline, + support_email, + footer_text, + watermark_enabled, + watermark_position, + watermark_opacity, + watermark_size, + favicon_url, + logo_url, + watermark_logo_url + } = req.body; + + const brandingSettings = { + company_name, + company_tagline, + support_email, + footer_text, + watermark_enabled, + watermark_position, + watermark_opacity, + watermark_size, + favicon_url, + logo_url, + watermark_logo_url + }; + + // Handle favicon deletion if empty string or null is provided + if (favicon_url === '' || favicon_url === null || favicon_url === undefined) { + // Get current favicon path to delete file + const currentFaviconSetting = await db('app_settings') + .where('setting_key', 'branding_favicon_url') + .first(); + + if (currentFaviconSetting && currentFaviconSetting.setting_value) { + let currentFaviconUrl; + try { + // Try to parse as JSON first + currentFaviconUrl = JSON.parse(currentFaviconSetting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + currentFaviconUrl = currentFaviconSetting.setting_value; + } + + if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) { + // Delete the file from filesystem + const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', '')); + try { + await fs.unlink(faviconPath); + console.log('Deleted favicon file:', faviconPath); + } catch (err) { + console.error('Error deleting favicon file:', err); + } + } + } + } + + // Handle logo deletion if empty string or null is provided + if (logo_url === '' || logo_url === null || logo_url === undefined) { + // Get current logo path to delete file + const currentLogoSetting = await db('app_settings') + .where('setting_key', 'branding_logo_url') + .first(); + + if (currentLogoSetting && currentLogoSetting.setting_value) { + let currentLogoUrl; + try { + // Try to parse as JSON first + currentLogoUrl = JSON.parse(currentLogoSetting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + currentLogoUrl = currentLogoSetting.setting_value; + } + + if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) { + // Delete the file from filesystem + const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', '')); + try { + await fs.unlink(logoPath); + console.log('Deleted logo file:', logoPath); + } catch (err) { + console.error('Error deleting logo file:', err); + } + } + } + } + + // Update or insert each setting + for (const [key, value] of Object.entries(brandingSettings)) { + await db('app_settings') + .insert({ + setting_key: `branding_${key}`, + setting_value: JSON.stringify(value), + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + // Log activity + await db('activity_logs').insert({ + activity_type: 'branding_updated', + actor_type: 'admin', + actor_id: req.admin.id, + actor_name: req.admin.username, + metadata: JSON.stringify({ company_name }) + }); + + res.json({ message: 'Branding settings updated successfully' }); + } catch (error) { + console.error('Branding update error:', error); + res.status(500).json({ error: 'Failed to update branding settings' }); + } +}); + +// Upload logo +router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No logo file uploaded' }); + } + + // Get old logo to delete + const oldLogoSetting = await db('app_settings') + .where('setting_key', 'branding_logo_path') + .first(); + + if (oldLogoSetting && oldLogoSetting.setting_value) { + const oldPath = JSON.parse(oldLogoSetting.setting_value); + try { + await fs.unlink(oldPath); + } catch (error) { + console.error('Failed to delete old logo:', error); + } + } + + // Save new logo path + const logoPath = req.file.path; + const publicPath = `/uploads/logos/${req.file.filename}`; + + await db('app_settings') + .insert({ + setting_key: 'branding_logo_path', + setting_value: JSON.stringify(logoPath), + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(logoPath), + updated_at: new Date() + }); + + // Save public URL + await db('app_settings') + .insert({ + setting_key: 'branding_logo_url', + setting_value: publicPath, + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: publicPath, + updated_at: new Date() + }); + + res.json({ + message: 'Logo uploaded successfully', + logoUrl: publicPath + }); + } catch (error) { + console.error('Logo upload error:', error); + res.status(500).json({ error: 'Failed to upload logo' }); + } +}); + +// Upload watermark logo +router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + // Delete old watermark logo if exists + const oldWatermarkLogoSetting = await db('app_settings') + .where('setting_key', 'branding_watermark_logo_path') + .first(); + + if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) { + const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value); + try { + await fs.unlink(oldPath); + } catch (error) { + console.error('Failed to delete old watermark logo:', error); + } + } + + // Save new watermark logo path + const logoPath = req.file.path; + const publicPath = `/uploads/logos/${req.file.filename}`; + + await db('app_settings') + .insert({ + setting_key: 'branding_watermark_logo_path', + setting_value: JSON.stringify(logoPath), + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(logoPath), + updated_at: new Date() + }); + + // Save public URL + await db('app_settings') + .insert({ + setting_key: 'branding_watermark_logo_url', + setting_value: publicPath, + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: publicPath, + updated_at: new Date() + }); + + res.json({ + message: 'Watermark logo uploaded successfully', + watermarkLogoUrl: publicPath + }); + } catch (error) { + console.error('Watermark logo upload error:', error); + res.status(500).json({ error: 'Failed to upload watermark logo' }); + } +}); + +// Update theme settings +router.put('/theme', adminAuth, async (req, res) => { + try { + const themeSettings = req.body; + + // Save theme settings + await db('app_settings') + .insert({ + setting_key: 'theme_config', + setting_value: JSON.stringify(themeSettings), + setting_type: 'theme', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(themeSettings), + updated_at: new Date() + }); + + // Log activity + await db('activity_logs').insert({ + activity_type: 'theme_updated', + actor_type: 'admin', + actor_id: req.admin.id, + actor_name: req.admin.username, + metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' }) + }); + + res.json({ message: 'Theme settings updated successfully' }); + } catch (error) { + console.error('Theme update error:', error); + res.status(500).json({ error: 'Failed to update theme settings' }); + } +}); + +// Update general settings +router.put('/general', adminAuth, async (req, res) => { + try { + const settings = req.body; + + // Update or insert each setting + for (const [key, value] of Object.entries(settings)) { + await db('app_settings') + .insert({ + setting_key: key, + setting_value: JSON.stringify(value), + setting_type: 'general', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + // Clear maintenance mode cache if it was updated + if ('general_maintenance_mode' in settings) { + clearMaintenanceCache(); + } + + // Log activity + await db('activity_logs').insert({ + activity_type: 'general_settings_updated', + actor_type: 'admin', + actor_id: req.admin.id, + actor_name: req.admin.username, + metadata: JSON.stringify({ settings_count: Object.keys(settings).length }) + }); + + res.json({ message: 'General settings updated successfully' }); + } catch (error) { + console.error('General settings update error:', error); + res.status(500).json({ error: 'Failed to update general settings' }); + } +}); + +// Update security settings +router.put('/security', adminAuth, async (req, res) => { + try { + const settings = req.body; + + // Update or insert each setting + for (const [key, value] of Object.entries(settings)) { + await db('app_settings') + .insert({ + setting_key: key, + setting_value: JSON.stringify(value), + setting_type: 'security', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + // Log activity + await db('activity_logs').insert({ + activity_type: 'security_settings_updated', + actor_type: 'admin', + actor_id: req.admin.id, + actor_name: req.admin.username, + metadata: JSON.stringify({ settings_count: Object.keys(settings).length }) + }); + + res.json({ message: 'Security settings updated successfully' }); + } catch (error) { + console.error('Security settings update error:', error); + res.status(500).json({ error: 'Failed to update security settings' }); + } +}); + +// Get storage info +router.get('/storage/info', adminAuth, async (req, res) => { + try { + // Get total storage used + const totalStorage = await db('photos') + .sum('size_bytes as total') + .first(); + + // Get storage by event + const storageByEvent = await db('photos') + .select('events.event_name', 'events.id') + .sum('photos.size_bytes as size') + .join('events', 'photos.event_id', 'events.id') + .groupBy('events.id') + .orderBy('size', 'desc') + .limit(10); + + // Get archive storage + const archives = await db('events') + .where('is_archived', formatBoolean(true)) + .whereNotNull('archive_path') + .select('archive_path'); + + let archiveStorage = 0; + for (const archive of archives) { + if (archive.archive_path) { + try { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const fullArchivePath = path.join(storagePath, archive.archive_path); + const stats = await fs.stat(fullArchivePath); + archiveStorage += stats.size; + } catch (error) { + console.error('Archive file not found:', archive.archive_path, error.message); + } + } + } + + res.json({ + total_used: totalStorage.total || 0, + archive_storage: archiveStorage, + storage_by_event: storageByEvent, + storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default + }); + } catch (error) { + console.error('Storage info error:', error); + res.status(500).json({ error: 'Failed to fetch storage information' }); + } +}); + +// Upload favicon endpoint +router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No favicon file provided' }); + } + + // The file is already in the correct location from multer + const faviconUrl = `/uploads/favicons/${req.file.filename}`; + + // Save to database + await db('app_settings') + .insert({ + setting_key: 'branding_favicon_url', + setting_value: faviconUrl, + setting_type: 'branding', + updated_at: new Date() + }) + .onConflict('setting_key') + .merge({ + setting_value: faviconUrl, + updated_at: new Date() + }); + + // Log activity + await logActivity('favicon_uploaded', + { faviconUrl }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ faviconUrl }); + } catch (error) { + console.error('Error uploading favicon:', error); + res.status(500).json({ error: 'Failed to upload favicon' }); + } +}); + +// Update rate limit settings +router.put('/security/rate-limit', adminAuth, [ + body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'), + body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'), + body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'), + body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'), + body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'), + body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + rate_limit_enabled, + rate_limit_window_minutes, + rate_limit_max_requests, + rate_limit_auth_max_requests, + rate_limit_skip_authenticated, + rate_limit_public_endpoints_only + } = req.body; + + // Update each setting + const settings = [ + { key: 'rate_limit_enabled', value: rate_limit_enabled }, + { key: 'rate_limit_window_minutes', value: rate_limit_window_minutes }, + { key: 'rate_limit_max_requests', value: rate_limit_max_requests }, + { key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests }, + { key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated }, + { key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only } + ]; + + for (const { key, value } of settings) { + await db('app_settings') + .where('setting_key', key) + .update({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + + // Clear the rate limit settings cache to apply changes immediately + clearSettingsCache(); + + // Log activity + await logActivity('settings_updated', + { + category: 'security', + subcategory: 'rate_limit', + changes: settings.length + }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Rate limit settings updated successfully' }); + } catch (error) { + console.error('Rate limit settings update error:', error); + res.status(500).json({ error: 'Failed to update rate limit settings' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js new file mode 100644 index 0000000..e56407d --- /dev/null +++ b/backend/src/routes/adminSystem.js @@ -0,0 +1,229 @@ +const express = require('express'); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); +const { formatBoolean } = require('../utils/dbCompat'); +const router = express.Router(); + +// Get system version +router.get('/version', adminAuth, async (req, res) => { + try { + // Read backend version from package.json + let backendVersion = '1.0.0'; + try { + const packagePath = path.join(__dirname, '../../package.json'); + const packageContent = await fs.readFile(packagePath, 'utf8'); + const packageJson = JSON.parse(packageContent); + backendVersion = packageJson.version || '1.0.0'; + } catch (err) { + console.error('Could not read package.json:', err); + } + + res.json({ + backend: backendVersion, + frontend: '1.0.0', // This will be set by frontend + node: process.version, + environment: process.env.NODE_ENV || 'production' + }); + } catch (error) { + console.error('Error fetching version:', error); + res.status(500).json({ error: 'Failed to fetch version information' }); + } +}); + +// Get comprehensive system status +router.get('/status', adminAuth, async (req, res) => { + try { + // Database size - check if PostgreSQL or SQLite + let dbSize = 0; + const dbClient = process.env.DATABASE_CLIENT || 'sqlite3'; + + if (dbClient === 'pg') { + // PostgreSQL - query database size + try { + const dbName = process.env.DB_NAME || 'picpeak'; + const result = await db.raw(` + SELECT pg_database_size(?) as size + `, [dbName]); + dbSize = result.rows[0]?.size || 0; + } catch (error) { + console.error('Error getting PostgreSQL database size:', error); + } + } else { + // SQLite - check file size + const dbPath = path.join(__dirname, '../../data/photo_sharing.db'); + try { + const stats = await fs.stat(dbPath); + dbSize = stats.size; + } catch (error) { + console.error('Error getting SQLite database size:', error); + } + } + + // Count various entities + const [eventsCount] = await db('events').count('* as count'); + const [photosCount] = await db('photos').count('* as count'); + const [adminsCount] = await db('admin_users').count('* as count'); + const [categoriesCount] = await db('photo_categories').count('* as count'); + + // Email queue status + const [pendingEmails] = await db('email_queue').where('status', 'pending').count('* as count'); + const [processableEmails] = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .count('* as count'); + const [sentEmails] = await db('email_queue').where('status', 'sent').count('* as count'); + const [failedEmails] = await db('email_queue').where('status', 'failed').count('* as count'); + const [stuckEmails] = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '>=', 3) + .count('* as count'); + + // Activity logs count + const [activityCount] = await db('activity_logs').count('* as count'); + + // Storage info + const [{ totalPhotoStorage }] = await db('photos') + .sum('size_bytes as totalPhotoStorage'); + + const archives = await db('events') + .where('is_archived', formatBoolean(true)) + .whereNotNull('archive_path') + .select('archive_path'); + + let archiveStorage = 0; + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + + for (const archive of archives) { + if (archive.archive_path) { + try { + const fullArchivePath = path.join(storagePath, archive.archive_path); + const stats = await fs.stat(fullArchivePath); + archiveStorage += stats.size; + } catch (error) { + console.error('Archive file not found:', archive.archive_path); + } + } + } + + const totalStorage = (parseInt(totalPhotoStorage) || 0) + archiveStorage; + + // System info + const systemInfo = { + platform: os.platform(), + arch: os.arch(), + hostname: os.hostname(), + uptime: Math.floor(process.uptime()), + nodeVersion: process.version, + memory: { + total: os.totalmem(), + free: os.freemem(), + used: os.totalmem() - os.freemem() + }, + cpu: { + model: os.cpus()[0]?.model || 'Unknown', + cores: os.cpus().length + } + }; + + // Build response + const status = { + database: { + size: dbSize, + tables: { + events: eventsCount.count, + photos: photosCount.count, + admins: adminsCount.count, + categories: categoriesCount.count, + activityLogs: activityCount.count + } + }, + storage: { + totalUsed: totalStorage, + photoStorage: parseInt(totalPhotoStorage) || 0, + archiveStorage: archiveStorage + }, + emailQueue: { + pending: pendingEmails.count, + processable: processableEmails.count, + stuck: stuckEmails.count, + sent: sentEmails.count, + failed: failedEmails.count + }, + system: systemInfo, + services: { + fileWatcher: { status: 'active' }, // These would ideally check actual service status + expirationChecker: { status: 'active' }, + emailProcessor: { status: 'active' } + }, + timestamp: new Date() + }; + + res.json(status); + } catch (error) { + console.error('Error fetching system status:', error); + res.status(500).json({ error: 'Failed to fetch system status' }); + } +}); + +// Get database statistics +router.get('/database', adminAuth, async (req, res) => { + try { + // Get table info + const tables = [ + 'events', 'photos', 'admin_users', 'photo_categories', + 'cms_pages', 'email_templates', 'email_queue', 'activity_logs', + 'app_settings', 'email_configs', 'access_logs', 'migrations' + ]; + + const tableInfo = []; + + for (const table of tables) { + try { + const [count] = await db(table).count('* as count'); + + // Get last update time + let lastUpdate = null; + try { + const lastRow = await db(table) + .orderBy('updated_at', 'desc') + .orOrderBy('created_at', 'desc') + .orOrderBy('timestamp', 'desc') + .orOrderBy('applied_at', 'desc') + .first(); + + if (lastRow) { + lastUpdate = lastRow.updated_at || lastRow.created_at || lastRow.timestamp || lastRow.applied_at; + } + } catch (e) { + // Table might not have timestamp columns + } + + tableInfo.push({ + name: table, + rows: count.count, + lastUpdate + }); + } catch (error) { + // Table might not exist + tableInfo.push({ + name: table, + rows: 0, + error: error.message + }); + } + } + + res.json({ + tables: tableInfo, + timestamp: new Date() + }); + } catch (error) { + console.error('Error fetching database info:', error); + res.status(500).json({ error: 'Failed to fetch database information' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/auth-enhanced-v2.js b/backend/src/routes/auth-enhanced-v2.js new file mode 100644 index 0000000..2f8eba7 --- /dev/null +++ b/backend/src/routes/auth-enhanced-v2.js @@ -0,0 +1,375 @@ +const express = require('express'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { body, validationResult } = require('express-validator'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { verifyRecaptcha } = require('../services/recaptcha'); +const { + trackFailedAttempt, + trackSuccessfulLogin, + checkAccountLockout, + checkSuspiciousActivity, + getGenericAuthError +} = require('../utils/authSecurity'); +const { + validatePasswordInContext, + getBcryptRounds, + logPasswordValidationFailure +} = require('../utils/passwordValidation'); +const { endSession } = require('../middleware/sessionTimeout'); +const logger = require('../utils/logger'); +const router = express.Router(); + +// Admin login with enhanced security +router.post('/admin/login', [ + body('username').notEmpty().trim(), + body('password').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, password, recaptchaToken } = req.body; + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent'] || ''; + + // Check account lockout first + const lockoutStatus = await checkAccountLockout(username); + if (lockoutStatus.isLocked) { + logger.warn('Login attempt on locked account', { username, ipAddress }); + return res.status(423).json({ + error: 'Account temporarily locked due to too many failed attempts', + retryAfter: lockoutStatus.remainingTime + }); + } + + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + // Check for suspicious activity + const isSuspicious = await checkSuspiciousActivity(username, ipAddress); + if (isSuspicious) { + // Still allow login but log it + logger.warn('Suspicious login pattern detected', { username, ipAddress }); + } + + const admin = await db('admin_users') + .where({ username }) + .orWhere({ email: username }) + .first(); + + // Use generic error to prevent user enumeration + if (!admin || !await bcrypt.compare(password, admin.password_hash)) { + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(401).json({ error: getGenericAuthError() }); + } + + if (!admin.is_active) { + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(401).json({ error: getGenericAuthError() }); + } + + // Successful login + await trackSuccessfulLogin(username, ipAddress, userAgent); + + // Update last login and login metadata + await db('admin_users').where('id', admin.id).update({ + last_login: new Date(), + last_login_ip: ipAddress + }); + + // Generate token with additional claims + const token = jwt.sign({ + id: admin.id, + username: admin.username, + type: 'admin', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + res.json({ + token, + user: { + id: admin.id, + username: admin.username, + email: admin.email, + mustChangePassword: admin.must_change_password || false + } + }); + } catch (error) { + logger.error('Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +// Admin password change with validation +router.post('/admin/change-password', [ + body('currentPassword').notEmpty(), + body('newPassword').notEmpty(), + body('confirmPassword').notEmpty() + .custom((value, { req }) => value === req.body.newPassword) + .withMessage('Passwords do not match') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { currentPassword, newPassword } = req.body; + const adminId = req.admin.id; // From auth middleware + + // Get admin user + const admin = await db('admin_users').where({ id: adminId }).first(); + if (!admin) { + return res.status(404).json({ error: 'User not found' }); + } + + // Verify current password + const validPassword = await bcrypt.compare(currentPassword, admin.password_hash); + if (!validPassword) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + // Validate new password + const passwordValidation = validatePasswordInContext(newPassword, 'admin', { + username: admin.username, + email: admin.email + }); + + if (!passwordValidation.valid) { + logPasswordValidationFailure('admin_password_change', passwordValidation.errors, { + userId: adminId, + username: admin.username + }); + + return res.status(400).json({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + + // Hash new password with configurable rounds + const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds()); + + // Update password and track change time + await db('admin_users').where('id', adminId).update({ + password_hash: hashedPassword, + password_changed_at: new Date(), + must_change_password: false + }); + + // Log password change + logger.info('Admin password changed', { + userId: adminId, + username: admin.username, + ip: req.ip + }); + + res.json({ + message: 'Password changed successfully', + score: passwordValidation.score + }); + } catch (error) { + logger.error('Password change error:', error); + res.status(500).json({ error: 'Failed to change password' }); + } +}); + +// Logout endpoint +router.post('/logout', async (req, res) => { + try { + const token = req.headers.authorization?.split(' ')[1]; + + if (token) { + // End the session + endSession(token); + + // Log the logout + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + logger.info('User logged out', { + userId: decoded.id, + username: decoded.username, + type: decoded.type + }); + } catch (err) { + // Token might be invalid, but still process logout + } + } + + res.json({ message: 'Logged out successfully' }); + } catch (error) { + logger.error('Logout error:', error); + res.status(500).json({ error: 'Logout failed' }); + } +}); + +// Gallery password verification with enhanced security +router.post('/gallery/verify', [ + body('slug').notEmpty().trim(), + body('password').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slug, password, recaptchaToken } = req.body; + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent'] || ''; + + // Check gallery-specific lockout + const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); + if (lockoutStatus.isLocked) { + logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); + return res.status(423).json({ + error: 'Too many failed attempts. Please try again later.', + retryAfter: lockoutStatus.remainingTime + }); + } + + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); + if (!event) { + // Don't reveal if gallery exists + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + // Successful access + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); + + // Log successful access + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_success' + }); + + // Generate session token with additional security info + const token = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + res.json({ + token, + event: { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + allow_user_uploads: event.allow_user_uploads, + upload_category_id: event.upload_category_id + } + }); + } catch (error) { + logger.error('Gallery verification error:', error); + res.status(500).json({ error: 'Verification failed' }); + } +}); + +// Get current session info +router.get('/session', async (req, res) => { + try { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Calculate remaining time + const now = Date.now() / 1000; + const remainingTime = Math.max(0, decoded.exp - now); + + res.json({ + valid: true, + type: decoded.type, + expiresIn: Math.floor(remainingTime), + user: decoded.username || decoded.eventSlug + }); + } catch (err) { + res.json({ + valid: false, + error: 'Invalid or expired token' + }); + } + } catch (error) { + res.status(500).json({ error: 'Session check failed' }); + } +}); + +// Password strength check endpoint (for real-time validation) +router.post('/password-strength', [ + body('password').notEmpty(), + body('context').isIn(['admin', 'gallery']).optional() +], async (req, res) => { + try { + const { password, context = 'gallery' } = req.body; + + // Get user data if available (for context-aware validation) + const userData = {}; + if (context === 'admin' && req.admin) { + userData.username = req.admin.username; + userData.email = req.admin.email; + } + + const validation = validatePasswordInContext(password, context, userData); + + res.json({ + valid: validation.valid, + score: validation.score, + errors: validation.errors, + feedback: validation.feedback + }); + } catch (error) { + res.status(500).json({ error: 'Failed to check password strength' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/auth-enhanced.js b/backend/src/routes/auth-enhanced.js new file mode 100644 index 0000000..dbc282d --- /dev/null +++ b/backend/src/routes/auth-enhanced.js @@ -0,0 +1,266 @@ +const express = require('express'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { body, validationResult } = require('express-validator'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { verifyRecaptcha } = require('../services/recaptcha'); +const { + trackFailedAttempt, + trackSuccessfulLogin, + checkAccountLockout, + checkSuspiciousActivity, + getGenericAuthError +} = require('../utils/authSecurity'); +const { endSession } = require('../middleware/sessionTimeout'); +const logger = require('../utils/logger'); +const router = express.Router(); + +// Admin login with enhanced security +router.post('/admin/login', [ + body('username').notEmpty().trim(), + body('password').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, password, recaptchaToken } = req.body; + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent'] || ''; + + // Check account lockout first + const lockoutStatus = await checkAccountLockout(username); + if (lockoutStatus.isLocked) { + logger.warn('Login attempt on locked account', { username, ipAddress }); + return res.status(423).json({ + error: 'Account temporarily locked due to too many failed attempts', + retryAfter: lockoutStatus.remainingTime + }); + } + + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + // Check for suspicious activity + const isSuspicious = await checkSuspiciousActivity(username, ipAddress); + if (isSuspicious) { + // Still allow login but log it + logger.warn('Suspicious login pattern detected', { username, ipAddress }); + } + + const admin = await db('admin_users') + .where({ username }) + .orWhere({ email: username }) + .first(); + + // Use generic error to prevent user enumeration + if (!admin || !await bcrypt.compare(password, admin.password_hash)) { + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(401).json({ error: getGenericAuthError() }); + } + + if (!admin.is_active) { + await trackFailedAttempt(username, ipAddress, userAgent); + return res.status(401).json({ error: getGenericAuthError() }); + } + + // Successful login + await trackSuccessfulLogin(username, ipAddress, userAgent); + + // Update last login and login metadata + await db('admin_users').where('id', admin.id).update({ + last_login: new Date(), + last_login_ip: ipAddress + }); + + // Generate token with additional claims + const token = jwt.sign({ + id: admin.id, + username: admin.username, + type: 'admin', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + res.json({ + token, + user: { + id: admin.id, + username: admin.username, + email: admin.email, + mustChangePassword: admin.must_change_password || false + } + }); + } catch (error) { + logger.error('Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +// Logout endpoint +router.post('/logout', async (req, res) => { + try { + const token = req.headers.authorization?.split(' ')[1]; + + if (token) { + // End the session + endSession(token); + + // Log the logout + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + logger.info('User logged out', { + userId: decoded.id, + username: decoded.username, + type: decoded.type + }); + } catch (err) { + // Token might be invalid, but still process logout + } + } + + res.json({ message: 'Logged out successfully' }); + } catch (error) { + logger.error('Logout error:', error); + res.status(500).json({ error: 'Logout failed' }); + } +}); + +// Gallery password verification with enhanced security +router.post('/gallery/verify', [ + body('slug').notEmpty().trim(), + body('password').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slug, password, recaptchaToken } = req.body; + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.headers['user-agent'] || ''; + + // Check gallery-specific lockout + const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); + if (lockoutStatus.isLocked) { + logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress }); + return res.status(423).json({ + error: 'Too many failed attempts. Please try again later.', + retryAfter: lockoutStatus.remainingTime + }); + } + + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); + if (!event) { + // Don't reveal if gallery exists + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid gallery or password' }); + } + + // Successful access + await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent); + + // Log successful access + await db('access_logs').insert({ + event_id: event.id, + ip_address: ipAddress, + user_agent: userAgent, + action: 'login_success' + }); + + // Generate session token with additional security info + const token = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery', + ip: ipAddress, + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '24h', + issuer: 'picpeak-auth' + }); + + res.json({ + token, + event: { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + allow_user_uploads: event.allow_user_uploads, + upload_category_id: event.upload_category_id + } + }); + } catch (error) { + logger.error('Gallery verification error:', error); + res.status(500).json({ error: 'Verification failed' }); + } +}); + +// Get current session info +router.get('/session', async (req, res) => { + try { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Calculate remaining time + const now = Date.now() / 1000; + const remainingTime = Math.max(0, decoded.exp - now); + + res.json({ + valid: true, + type: decoded.type, + expiresIn: Math.floor(remainingTime), + user: decoded.username || decoded.eventSlug + }); + } catch (err) { + res.json({ + valid: false, + error: 'Invalid or expired token' + }); + } + } catch (error) { + res.status(500).json({ error: 'Session check failed' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..08ecd07 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,131 @@ +const express = require('express'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); +const { body, validationResult } = require('express-validator'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { verifyRecaptcha } = require('../services/recaptcha'); +const router = express.Router(); + +// Admin login +router.post('/admin/login', [ + body('username').notEmpty(), + body('password').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, password, recaptchaToken } = req.body; + + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + const admin = await db('admin_users') + .where({ username }) + .orWhere({ email: username }) + .first(); + + if (!admin || !await bcrypt.compare(password, admin.password_hash)) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + if (!admin.is_active) { + return res.status(401).json({ error: 'Account disabled' }); + } + + // Update last login + await db('admin_users').where('id', admin.id).update({ last_login: new Date() }); + + const token = jwt.sign({ id: admin.id, type: 'admin' }, process.env.JWT_SECRET, { expiresIn: '24h' }); + + res.json({ + token, + user: { + id: admin.id, + username: admin.username, + email: admin.email, + mustChangePassword: admin.must_change_password || false + } + }); + } catch (error) { + res.status(500).json({ error: 'Login failed' }); + } +}); + +// Gallery password verification +router.post('/gallery/verify', [ + body('slug').notEmpty(), + body('password').notEmpty() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { slug, password, recaptchaToken } = req.body; + + // Verify reCAPTCHA + const recaptchaValid = await verifyRecaptcha(recaptchaToken); + if (!recaptchaValid) { + return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + } + + const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); + if (!event) { + return res.status(404).json({ error: 'Gallery not found or expired' }); + } + + const validPassword = await bcrypt.compare(password, event.password_hash); + if (!validPassword) { + await db('access_logs').insert({ + event_id: event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'login_fail' + }); + return res.status(401).json({ error: 'Invalid password' }); + } + + // Log successful access + await db('access_logs').insert({ + event_id: event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'login_success' + }); + + // Generate session token + const token = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery' + }, process.env.JWT_SECRET, { expiresIn: '24h' }); + + res.json({ + token, + event: { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + allow_user_uploads: event.allow_user_uploads, + upload_category_id: event.upload_category_id, + hero_photo_id: event.hero_photo_id + } + }); + } catch (error) { + res.status(500).json({ error: 'Verification failed' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js new file mode 100644 index 0000000..a23ee0c --- /dev/null +++ b/backend/src/routes/events.js @@ -0,0 +1,201 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const fs = require('fs').promises; +const path = require('path'); +const router = express.Router(); + +// Create new event +router.post('/', adminAuth, [ + body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), + body('event_name').notEmpty(), + body('event_date').isDate(), + body('host_email').isEmail(), + body('admin_email').isEmail(), + body('password').isLength({ min: 6 }), + body('expiration_days').isInt({ min: 1, max: 365 }).optional() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { + event_type, + event_name, + event_date, + host_email, + admin_email, + password, + welcome_message, + color_theme, + expiration_days = 30 + } = req.body; + + // Generate unique slug + const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`; + let slug = baseSlug; + let counter = 1; + + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + // Generate share link + const shareToken = crypto.randomBytes(16).toString('hex'); + const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; + + // Hash password + const password_hash = await bcrypt.hash(password, 10); + + // Calculate expiration date (days after event date) + const expires_at = new Date(event_date); + expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); + + // Create folder structure + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + + // Insert into database + const insertResult = await db('events').insert({ + slug, + event_type, + event_name, + event_date, + host_email, + admin_email, + password_hash, + welcome_message, + color_theme, + share_link: shareLink, + expires_at + }).returning('id'); + + // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) + const eventId = insertResult[0]?.id || insertResult[0]; + + // Queue creation email + const { queueEmail } = require('../services/emailProcessor'); + await queueEmail(eventId, host_email, 'gallery_created', { + host_name: host_email.split('@')[0], // Extract name from email + event_name, + event_date: event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareLink, + gallery_password: password, + expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor + welcome_message: welcome_message || '' + }); + + res.json({ + id: eventId, + slug, + share_link: shareLink, + expires_at + }); + } catch (error) { + console.error(error); + res.status(500).json({ error: 'Failed to create event' }); + } +}); + +// Get all events (admin) +router.get('/', adminAuth, async (req, res) => { + try { + const { status = 'all' } = req.query; + + let query = db('events').select('*'); + + if (status === 'active') { + query = query.where('is_active', formatBoolean(true)); + } else if (status === 'archived') { + query = query.where('is_archived', formatBoolean(true)); + } + + const events = await query.orderBy('created_at', 'desc'); + + // Add photo counts + for (const event of events) { + const photoCount = await db('photos').where('event_id', event.id).count('id as count').first(); + event.photo_count = photoCount.count; + } + + res.json(events); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch events' }); + } +}); + +// Update event +router.put('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + const updates = req.body; + + // Don't allow updating certain fields + delete updates.id; + delete updates.slug; + delete updates.created_at; + + // If updating password, hash it + if (updates.password) { + updates.password_hash = await bcrypt.hash(updates.password, 10); + delete updates.password; + } + + await db('events').where('id', id).update(updates); + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: 'Failed to update event' }); + } +}); + +// Delete event (mark as inactive) +router.delete('/:id', adminAuth, async (req, res) => { + try { + const { id } = req.params; + + await db('events').where('id', id).update({ is_active: formatBoolean(false) }); + + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete event' }); + } +}); + +// Extend expiration +router.post('/:id/extend', adminAuth, [ + body('days').isInt({ min: 1, max: 365 }) +], async (req, res) => { + try { + const { id } = req.params; + const { days } = req.body; + + const event = await db('events').where('id', id).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const newExpiration = new Date(event.expires_at); + newExpiration.setDate(newExpiration.getDate() + days); + + await db('events').where('id', id).update({ + expires_at: newExpiration, + is_active: formatBoolean(true) // Reactivate if expired + }); + + res.json({ expires_at: newExpiration }); + } catch (error) { + res.status(500).json({ error: 'Failed to extend expiration' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js new file mode 100644 index 0000000..025f6d0 --- /dev/null +++ b/backend/src/routes/gallery.js @@ -0,0 +1,467 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const archiver = require('archiver'); +const path = require('path'); +const router = express.Router(); +const watermarkService = require('../services/watermarkService'); +const { verifyGalleryAccess } = require('../middleware/gallery'); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Verify share token +router.get('/:slug/verify-token/:token', async (req, res) => { + try { + const { slug, token } = req.params; + + const event = await db('events') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .select('id', 'share_link') + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + // Extract token from share link and verify + const expectedToken = event.share_link.split('/').pop(); + if (token !== expectedToken) { + return res.status(404).json({ error: 'Invalid gallery link' }); + } + + res.json({ valid: true }); + } catch (error) { + console.error('Error verifying token:', error); + res.status(500).json({ error: 'Failed to verify token', details: error.message }); + } +}); + +// Get gallery info (with optional token verification) +router.get('/:slug/info', async (req, res) => { + try { + const { slug } = req.params; + const { token } = req.query; + + const event = await db('events') + .where({ slug }) + .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link') + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + // Check if event is archived + if (event.is_archived) { + return res.status(404).json({ error: 'Gallery has been archived and is no longer available' }); + } + + // If token provided, verify it matches the share link + if (token) { + let expectedToken = event.share_link; + // Handle both formats: full URL or just token + if (event.share_link && event.share_link.includes('/')) { + expectedToken = event.share_link.split('/').pop(); + } + if (token !== expectedToken) { + return res.status(404).json({ error: 'Invalid gallery link' }); + } + } + + res.json({ + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + expires_at: event.expires_at, + is_active: event.is_active, + is_expired: !event.is_active || new Date(event.expires_at) < new Date(), + requires_password: true, + color_theme: event.color_theme + }); + } catch (error) { + console.error('Error fetching gallery info:', error); + res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message }); + } +}); + +// Get all photos +router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { + try { + const photos = await db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', req.event.id) + .select( + 'photos.*', + 'photo_categories.name as category_name', + 'photo_categories.slug as category_slug' + ) + .orderBy('photos.uploaded_at', 'desc'); + + // Get all categories for this event + const categories = await db('photo_categories') + .where(function() { + this.where('is_global', formatBoolean(true)) + .orWhere('event_id', req.event.id); + }) + .orderBy('is_global', 'desc') + .orderBy('name', 'asc'); + + // Log view + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'view' + }); + + res.json({ + event: { + id: req.event.id, + event_name: req.event.event_name, + event_type: req.event.event_type, + event_date: req.event.event_date, + welcome_message: req.event.welcome_message, + color_theme: req.event.color_theme, + expires_at: req.event.expires_at, + hero_photo_id: req.event.hero_photo_id + }, + categories: categories.map(cat => ({ + id: cat.id, + name: cat.name, + slug: cat.slug, + is_global: cat.is_global + })), + photos: photos.map(photo => ({ + id: photo.id, + filename: photo.filename, + url: `/api/gallery/${req.params.slug}/photo/${photo.id}`, + thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null, + type: photo.type, + category_id: photo.category_id, + category_name: photo.category_name, + category_slug: photo.category_slug, + size: photo.size_bytes, + uploaded_at: photo.uploaded_at + })) + }); + } catch (error) { + console.error('Error fetching photos:', error); + res.status(500).json({ error: 'Failed to fetch photos', details: error.message }); + } +}); + +// Download single photo +router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Update download count + await db('photos').where('id', photoId).increment('download_count', 1); + + // Log download + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download', + photo_id: photoId + }); + + const filePath = path.join(getStoragePath(), 'events/active', photo.path); + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + if (watermarkSettings && watermarkSettings.enabled) { + // Apply watermark and send + const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); + + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Disposition': `attachment; filename="${photo.filename}"`, + 'Content-Length': watermarkedBuffer.length + }); + + res.send(watermarkedBuffer); + } else { + // Send original file + res.download(filePath, photo.filename); + } + } catch (error) { + res.status(500).json({ error: 'Failed to download photo' }); + } +}); + +// Download all photos as ZIP +router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { + try { + // Fetch photos with category information + const photos = await db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', req.event.id) + .select( + 'photos.*', + 'photo_categories.name as category_name', + 'photo_categories.slug as category_slug' + ) + .orderBy('photo_categories.name', 'asc') + .orderBy('photos.uploaded_at', 'desc'); + + if (photos.length === 0) { + return res.status(404).json({ error: 'No photos found' }); + } + + // Count unique categories (excluding null) + const uniqueCategories = new Set(photos.filter(p => p.category_id).map(p => p.category_id)).size; + const hasMultipleCategories = uniqueCategories > 1; + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); + + const archive = archiver('zip', { zlib: { level: 5 } }); + archive.on('error', (err) => { + throw err; + }); + + archive.pipe(res); + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + // Add photos to archive + for (const photo of photos) { + const filePath = path.join(getStoragePath(), 'events/active', photo.path); + + // Determine the file name in the archive + let archiveName; + if (hasMultipleCategories) { + if (photo.category_name) { + // Use category name as folder (sanitize for filesystem) + const folderName = photo.category_name.replace(/[^a-zA-Z0-9-_ ]/g, '').trim(); + archiveName = path.join(folderName, photo.filename); + } else { + // Put uncategorized photos in 'Uncategorized' folder + archiveName = path.join('Uncategorized', photo.filename); + } + } else { + // No folders, just the filename + archiveName = photo.filename; + } + + if (watermarkSettings && watermarkSettings.enabled) { + // Apply watermark + const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); + archive.append(watermarkedBuffer, { name: archiveName }); + } else { + // Add original file + archive.file(filePath, { name: archiveName }); + } + } + + await archive.finalize(); + + // Log bulk download + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download_all' + }); + } catch (error) { + res.status(500).json({ error: 'Failed to create download archive' }); + } +}); + +// View single photo (with watermark if enabled) +router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + const filePath = path.join(getStoragePath(), 'events/active', photo.path); + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + if (watermarkSettings && watermarkSettings.enabled) { + // Apply watermark and send + const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); + + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour + }); + + res.send(watermarkedBuffer); + } else { + // Send original file + res.sendFile(filePath); + } + } catch (error) { + console.error('Error serving photo:', error); + res.status(500).json({ error: 'Failed to serve photo' }); + } +}); + +// Serve thumbnail +router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo || !photo.thumbnail_path) { + return res.status(404).json({ error: 'Thumbnail not found' }); + } + + const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + + // Check if file exists + const fs = require('fs').promises; + try { + await fs.access(thumbPath); + } catch (error) { + return res.status(404).json({ error: 'Thumbnail file not found' }); + } + + // Set appropriate headers + res.setHeader('Content-Type', 'image/jpeg'); + res.setHeader('Cache-Control', 'private, max-age=3600'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + + // Send file + res.sendFile(path.resolve(thumbPath)); + } catch (error) { + console.error('Error serving thumbnail:', error); + res.status(500).json({ error: 'Failed to serve thumbnail' }); + } +}); + +// Get photo stats +router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => { + try { + const totalPhotos = await db('photos') + .where('event_id', req.event.id) + .count('id as count') + .first(); + + const totalViews = await db('access_logs') + .where('event_id', req.event.id) + .where('action', 'view') + .count('id as count') + .first(); + + const totalDownloads = await db('photos') + .where('event_id', req.event.id) + .sum('download_count as total') + .first(); + + const uniqueVisitors = await db('access_logs') + .where('event_id', req.event.id) + .countDistinct('ip_address as count') + .first(); + + res.json({ + total_photos: totalPhotos.count, + total_views: totalViews.count, + total_downloads: totalDownloads.total || 0, + unique_visitors: uniqueVisitors.count + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch stats' }); + } +}); + +// User photo upload endpoint +router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => { + try { + const eventId = parseInt(req.params.eventId); + + // Verify the event matches the token + if (req.event.id !== eventId) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Check if user uploads are allowed + if (!req.event.allow_user_uploads) { + return res.status(403).json({ error: 'User uploads are not allowed for this event' }); + } + + // Import multer and photo processing + const multer = require('multer'); + const upload = multer({ + dest: '/tmp/uploads/', + limits: { + fileSize: 50 * 1024 * 1024, // 50MB + files: 10 // Max 10 files at once + }, + fileFilter: (req, file, cb) => { + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Invalid file type')); + } + } + }).array('photos', 10); + + // Handle upload + upload(req, res, async (err) => { + if (err) { + console.error('Upload error:', err); + return res.status(400).json({ error: err.message }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const { processUploadedPhotos } = require('../services/photoProcessor'); + const categoryId = req.body.category_id || req.event.upload_category_id || null; + + try { + // Process uploaded photos + const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId); + + // Clean up temp files + const fs = require('fs').promises; + for (const file of req.files) { + await fs.unlink(file.path).catch(console.error); + } + + res.json({ + message: 'Photos uploaded successfully', + count: results.length, + photos: results + }); + } catch (processError) { + console.error('Photo processing error:', processError); + res.status(500).json({ error: 'Failed to process photos' }); + } + }); + } catch (error) { + console.error('Upload route error:', error); + res.status(500).json({ error: 'Failed to upload photos' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js new file mode 100644 index 0000000..0004d9a --- /dev/null +++ b/backend/src/routes/protectedImages.js @@ -0,0 +1,190 @@ +const express = require('express'); +const path = require('path'); +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { verifyGalleryAccess } = require('../middleware/gallery'); +const watermarkService = require('../services/watermarkService'); +const { getStoragePath } = require('../config/storage'); +const crypto = require('crypto'); + +const router = express.Router(); + +/** + * Generate a signed URL token for image access + */ +function generateImageToken(photoId, expiresIn = 3600) { + const secret = process.env.JWT_SECRET; + const expires = Date.now() + (expiresIn * 1000); + const data = `${photoId}:${expires}`; + const signature = crypto.createHmac('sha256', secret).update(data).digest('hex'); + return `${Buffer.from(data).toString('base64')}.${signature}`; +} + +/** + * Verify image token + */ +function verifyImageToken(token) { + try { + const secret = process.env.JWT_SECRET; + const [data, signature] = token.split('.'); + const decoded = Buffer.from(data, 'base64').toString(); + const [photoId, expires] = decoded.split(':'); + + // Verify signature + const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex'); + if (signature !== expectedSignature) { + return null; + } + + // Check expiration + if (Date.now() > parseInt(expires)) { + return null; + } + + return { photoId: parseInt(photoId), expires: parseInt(expires) }; + } catch (error) { + return null; + } +} + +/** + * Serve watermarked image + */ +router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + + // Get photo details + const photo = await db('photos') + .where({ + id: photoId, + event_id: req.event.id + }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + // Build full path to photo + const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); + + // Apply watermark if enabled + const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings); + + // Set appropriate headers + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'private, max-age=3600', + 'X-Content-Type-Options': 'nosniff' + }); + + // Send the watermarked image + res.send(imageBuffer); + + } catch (error) { + console.error('Error serving watermarked image:', error); + res.status(500).json({ error: 'Failed to serve image' }); + } +}); + +/** + * Generate signed URL for image access + */ +router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + + // Verify photo belongs to this event + const photo = await db('photos') + .where({ + id: photoId, + event_id: req.event.id + }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Generate signed token + const token = generateImageToken(photoId); + const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`; + + res.json({ + url: signedUrl, + expiresIn: 3600 // 1 hour + }); + + } catch (error) { + console.error('Error generating signed URL:', error); + res.status(500).json({ error: 'Failed to generate URL' }); + } +}); + +/** + * Serve image with signed URL (no gallery auth required, token is the auth) + */ +router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => { + try { + const { slug, photoId, token } = req.params; + + // Verify token + const tokenData = verifyImageToken(token); + if (!tokenData || tokenData.photoId !== parseInt(photoId)) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } + + // Get event + const event = await db('events') + .where({ slug }) + .where('is_active', formatBoolean(true)) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Get photo + const photo = await db('photos') + .where({ + id: photoId, + event_id: event.id + }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + // Build full path to photo + const photoPath = path.join(getStoragePath(), 'events/active', event.slug, photo.path); + + // Apply watermark if enabled + const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings); + + // Set appropriate headers + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'private, max-age=3600', + 'X-Content-Type-Options': 'nosniff' + }); + + // Send the watermarked image + res.send(imageBuffer); + + } catch (error) { + console.error('Error serving signed image:', error); + res.status(500).json({ error: 'Failed to serve image' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/publicCMS.js b/backend/src/routes/publicCMS.js new file mode 100644 index 0000000..8a69f27 --- /dev/null +++ b/backend/src/routes/publicCMS.js @@ -0,0 +1,33 @@ +const express = require('express'); +const { db } = require('../database/db'); +const router = express.Router(); + +// Get public CMS page +router.get('/pages/:slug', async (req, res) => { + try { + const { slug } = req.params; + const { lang = 'en' } = req.query; + + const page = await db('cms_pages').where('slug', slug).first(); + + if (!page) { + return res.status(404).json({ error: 'Page not found' }); + } + + // Return the appropriate language version + const title = lang === 'de' ? page.title_de : page.title_en; + const content = lang === 'de' ? page.content_de : page.content_en; + + res.json({ + title, + content, + slug: page.slug, + updated_at: page.updated_at + }); + } catch (error) { + console.error('Error fetching public CMS page:', error); + res.status(500).json({ error: 'Failed to fetch page' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js new file mode 100644 index 0000000..d278b74 --- /dev/null +++ b/backend/src/routes/publicSettings.js @@ -0,0 +1,54 @@ +const express = require('express'); +const { db } = require('../database/db'); +const router = express.Router(); + +// Get public settings (branding and theme) +router.get('/', async (req, res) => { + try { + // Fetch branding, theme, general, and select security settings + const settings = await db('app_settings') + .whereIn('setting_type', ['branding', 'theme', 'general', 'security']) + .select('setting_key', 'setting_value'); + + // Convert to object format + const settingsObject = {}; + settings.forEach(setting => { + try { + settingsObject[setting.setting_key] = setting.setting_value + ? JSON.parse(setting.setting_value) + : null; + } catch (e) { + // If parsing fails, use the raw value + settingsObject[setting.setting_key] = setting.setting_value; + } + }); + + // Return only safe public settings + const publicSettings = { + branding_company_name: settingsObject.branding_company_name || '', + branding_company_tagline: settingsObject.branding_company_tagline || '', + branding_support_email: settingsObject.branding_support_email || '', + branding_footer_text: settingsObject.branding_footer_text || '', + branding_watermark_enabled: settingsObject.branding_watermark_enabled || false, + branding_watermark_logo_url: settingsObject.branding_watermark_logo_url || '', + branding_watermark_position: settingsObject.branding_watermark_position || 'bottom-right', + branding_watermark_opacity: settingsObject.branding_watermark_opacity || 50, + branding_watermark_size: settingsObject.branding_watermark_size || 15, + branding_favicon_url: settingsObject.branding_favicon_url || '', + branding_logo_url: settingsObject.branding_logo_url || '', + theme_config: settingsObject.theme_config || null, + default_language: settingsObject.general_default_language || 'en', + enable_analytics: settingsObject.general_enable_analytics !== false, + enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true', + recaptcha_site_key: settingsObject.security_recaptcha_site_key || null, + maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true' + }; + + res.json(publicSettings); + } catch (error) { + console.error('Public settings fetch error:', error); + res.status(500).json({ error: 'Failed to fetch settings' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js new file mode 100644 index 0000000..3f6a963 --- /dev/null +++ b/backend/src/services/archiveService.js @@ -0,0 +1,70 @@ +const archiver = require('archiver'); +const fs = require('fs').promises; +const path = require('path'); +const { db } = require('../database/db'); +const { queueEmail } = require('./emailProcessor'); +const logger = require('../utils/logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +const ACTIVE_PATH = () => path.join(getStoragePath(), 'events/active'); +const ARCHIVE_PATH = () => path.join(getStoragePath(), 'events/archived'); + +async function archiveEvent(event) { + try { + const eventPath = path.join(ACTIVE_PATH(), event.slug); + const archiveName = `${event.slug}.zip`; + const archivePath = path.join(ARCHIVE_PATH(), archiveName); + + // Ensure archive directory exists + await fs.mkdir(ARCHIVE_PATH(), { recursive: true }); + + // Create archive + const output = require('fs').createWriteStream(archivePath); + const archive = archiver('zip', { + zlib: { level: 9 } // Maximum compression + }); + + archive.on('error', (err) => { + throw err; + }); + + output.on('close', async () => { + logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`); + + // Update database + await db('events').where('id', event.id).update({ + is_archived: true, + archive_path: path.relative(getStoragePath(), archivePath), + archived_at: new Date() + }); + + // Delete original files + await fs.rm(eventPath, { recursive: true }); + + // Delete thumbnails + const photos = await db('photos').where('event_id', event.id); + for (const photo of photos) { + if (photo.thumbnail_path) { + const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted + } + } + + // Queue completion email + await queueEmail(event.id, event.admin_email, 'archive_complete', { + event_name: event.event_name, + archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB' + }); + }); + + archive.pipe(output); + archive.directory(eventPath, false); + await archive.finalize(); + + } catch (error) { + logger.error(`Error archiving event ${event.slug}:`, error); + throw error; + } +} + +module.exports = { archiveEvent }; diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js new file mode 100644 index 0000000..ba53c8f --- /dev/null +++ b/backend/src/services/emailProcessor.js @@ -0,0 +1,549 @@ +const nodemailer = require('nodemailer'); +const Handlebars = require('handlebars'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +let transporter = null; +let lastConfigHash = null; + +// Generate hash from config for change detection +function generateConfigHash(config) { + const crypto = require('crypto'); + const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`; + return crypto.createHash('md5').update(configString).digest('hex'); +} + +// Initialize transporter from database config +async function initializeTransporter(forceReinit = false) { + try { + const config = await db('email_configs').first(); + + if (!config) { + logger.warn('No email configuration found'); + return null; + } + + // Check if configuration has changed + const currentConfigHash = generateConfigHash(config); + if (!forceReinit && transporter && currentConfigHash === lastConfigHash) { + // Configuration hasn't changed, return existing transporter + return transporter; + } + + // Configuration has changed or first initialization + logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : '')); + + transporter = nodemailer.createTransport({ + host: config.smtp_host, + port: config.smtp_port, + secure: config.smtp_secure, + auth: config.smtp_user ? { + user: config.smtp_user, + pass: config.smtp_pass + } : undefined + }); + + // Verify configuration + await transporter.verify(); + logger.info('Email transporter initialized successfully'); + + // Update the config hash + lastConfigHash = currentConfigHash; + + return transporter; + } catch (error) { + logger.error('Failed to initialize email transporter:', error); + transporter = null; + lastConfigHash = null; + return null; + } +} + +// Get the appropriate language for a recipient +async function getRecipientLanguage(email, eventId = null) { + // First priority: Check event language setting if eventId is provided + if (eventId) { + try { + const event = await db('events').where('id', eventId).first(); + if (event && event.language) { + return event.language; + } + } catch (error) { + logger.error('Error fetching event language:', error); + } + } + + // Second priority: Check app_settings for general default language + try { + const langSetting = await db('app_settings') + .where('setting_key', 'general_default_language') + .first(); + if (langSetting && langSetting.setting_value) { + return langSetting.setting_value; + } + } catch (error) { + logger.error('Error fetching app settings language:', error); + } + + // Third priority: Check email configs for default language + try { + const emailConfig = await db('email_configs').first(); + if (emailConfig && emailConfig.default_language) { + return emailConfig.default_language; + } + } catch (error) { + logger.error('Error fetching email config language:', error); + } + + // Fourth priority: Check if the email domain suggests German + if (email) { + const germanDomains = ['.de', '.at', '.ch', '.li']; + const domain = email.toLowerCase(); + if (germanDomains.some(d => domain.endsWith(d))) { + return 'de'; + } + } + + return 'en'; // Default to English +} + +// Process email template with variables +async function processTemplate(template, variables, language = 'en') { + // Import date formatter and text formatters + const { formatDate } = require('../utils/dateFormatter'); + const { formatWelcomeMessage } = require('../utils/formatters'); + + // Get the appropriate language fields + const subjectField = language === 'de' ? 'subject_de' : 'subject_en'; + const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en'; + const textField = language === 'de' ? 'body_text_de' : 'body_text_en'; + + // Fall back to non-language-specific fields for backward compatibility + let subject = template[subjectField] || template.subject || ''; + let htmlBody = template[htmlField] || template.body_html || ''; + let textBody = template[textField] || template.body_text || ''; + + // Process variables before template compilation + const processedVariables = { ...variables }; + + // Handle password security message + if (processedVariables.gallery_password === '{{password_security_message}}') { + processedVariables.gallery_password = language === 'de' + ? '(Aus Sicherheitsgrรผnden nicht angezeigt)' + : '(Not shown for security reasons)'; + } + + // Format dates if they exist + if (processedVariables.event_date) { + processedVariables.event_date = await formatDate(processedVariables.event_date, language); + } + if (processedVariables.expiry_date) { + processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language); + } + if (processedVariables.archive_date) { + processedVariables.archive_date = await formatDate(processedVariables.archive_date, language); + } + + // Format welcome message for HTML display (preserve line breaks) + if (processedVariables.welcome_message) { + processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message); + } + + // Get branding settings for logo + let logoUrl = ''; + let companyName = 'PicPeak'; + try { + const brandingSettings = await db('app_settings') + .whereIn('setting_key', ['branding_logo_url', 'branding_company_name']) + .select('setting_key', 'setting_value'); + + brandingSettings.forEach(setting => { + if (setting.setting_key === 'branding_logo_url' && setting.setting_value) { + try { + logoUrl = JSON.parse(setting.setting_value); + } catch (e) { + logoUrl = setting.setting_value; + } + } else if (setting.setting_key === 'branding_company_name' && setting.setting_value) { + try { + companyName = JSON.parse(setting.setting_value); + } catch (e) { + companyName = setting.setting_value; + } + } + }); + } catch (error) { + logger.error('Error fetching branding settings:', error); + } + + // If no custom logo, use default PicPeak logo + const apiUrl = process.env.API_URL || 'http://localhost:3001'; + const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005'; + const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`; + + // Compile templates with Handlebars + const subjectTemplate = Handlebars.compile(subject); + const htmlTemplate = Handlebars.compile(htmlBody); + const textTemplate = Handlebars.compile(textBody); + + // Process templates with processedVariables (includes formatted dates and security messages) + subject = subjectTemplate(processedVariables); + htmlBody = htmlTemplate(processedVariables); + textBody = textTemplate(processedVariables); + + // Wrap HTML body in styled template + const styledHtmlBody = ` + + + + + + ${subject} + + + + + +`; + + return { subject, htmlBody: styledHtmlBody, textBody }; +} + +// Send email using template +async function sendTemplateEmail(to, templateKey, variables) { + try { + // Always check for configuration changes before sending + transporter = await initializeTransporter(); + if (!transporter) { + throw new Error('Email service not configured'); + } + + // Get email template + const template = await db('email_templates') + .where('template_key', templateKey) + .first(); + + if (!template) { + throw new Error(`Email template '${templateKey}' not found`); + } + + // Get email config for from address + const config = await db('email_configs').first(); + if (!config) { + throw new Error('Email configuration not found'); + } + + // Determine recipient language (pass eventId if available in variables) + const language = await getRecipientLanguage(to, variables.eventId || null); + + // Process template with variables + const { subject, htmlBody, textBody } = await processTemplate(template, variables, language); + + // Send email + const info = await transporter.sendMail({ + from: `${config.from_name} <${config.from_email}>`, + to: to, + subject: subject, + html: htmlBody, + text: textBody || htmlBody.replace(/<[^>]*>/g, '') // Strip HTML if no text version + }); + + logger.info(`Email sent successfully: ${info.messageId} (${language})`); + return { success: true, messageId: info.messageId, language }; + } catch (error) { + logger.error('Error sending template email:', error); + throw error; + } +} + +// Process email queue +async function processEmailQueue() { + logger.info('Email queue processor: Checking for pending emails...'); + + try { + // Try to initialize transporter if it's null (in case it failed at startup) + if (!transporter) { + logger.info('Transporter not initialized, attempting to initialize...'); + transporter = await initializeTransporter(); + if (!transporter) { + logger.warn('Email transporter could not be initialized, skipping queue processing'); + return; + } + } + + let pendingEmails = []; + try { + pendingEmails = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .orderBy('created_at', 'asc') + .limit(10); + } catch (dbError) { + logger.error('Failed to query email queue:', dbError); + return; + } + + if (pendingEmails.length === 0) { + logger.info('Email queue processor: No pending emails found'); + return; + } + + logger.info(`Processing ${pendingEmails.length} emails from queue`); + + for (const email of pendingEmails) { + try { + const emailData = typeof email.email_data === 'string' + ? JSON.parse(email.email_data || '{}') + : email.email_data || {}; + + await sendTemplateEmail( + email.recipient_email, + email.email_type, + emailData + ); + + // Mark as sent + await db('email_queue') + .where('id', email.id) + .update({ + status: 'sent', + sent_at: new Date() + }); + + logger.info(`Email ${email.id} sent successfully`); + } catch (error) { + // Increment retry count + try { + await db('email_queue') + .where('id', email.id) + .update({ + retry_count: email.retry_count + 1, + error_message: error.message + }); + } catch (updateError) { + logger.error(`Failed to update email retry count for ${email.id}:`, updateError); + // If update fails due to column issue, try without any potential auto-added fields + if (updateError.message && updateError.message.includes('updated_at')) { + logger.warn('Detected updated_at column issue, attempting raw query...'); + await db.raw( + 'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?', + [email.retry_count + 1, error.message, email.id] + ); + } + } + + logger.error(`Failed to send email ${email.id}:`, error); + } + } + } catch (error) { + logger.error('Error processing email queue:', error); + } +} + +// Queue an email for sending +async function queueEmail(eventId, recipientEmail, emailType, emailData) { + try { + // Add eventId to emailData for language detection + emailData.eventId = eventId; + await db('email_queue').insert({ + event_id: eventId, + recipient_email: recipientEmail, + email_type: emailType, + email_data: JSON.stringify(emailData), + status: 'pending', + retry_count: 0, + created_at: new Date() + }); + + logger.info(`Email queued: ${emailType} to ${recipientEmail}`); + } catch (error) { + logger.error('Error queueing email:', error); + throw error; + } +} + +// Test email connection +async function testEmailConnection() { + try { + if (!transporter) { + await initializeTransporter(); + } + if (!transporter) { + return false; + } + await transporter.verify(); + return true; + } catch (error) { + logger.error('Email connection test failed:', error); + return false; + } +} + +// Start email queue processor +let emailQueueInterval = null; + +function startEmailQueueProcessor() { + logger.info('Email queue processor: Attempting to start...'); + + if (!emailQueueInterval) { + // Process immediately on start + processEmailQueue().catch(err => { + logger.error('Email queue processor: Initial processing failed:', err); + }); + + // Then process every minute + emailQueueInterval = setInterval(() => { + processEmailQueue().catch(err => { + logger.error('Email queue processor: Periodic processing failed:', err); + }); + }, 60000); + + logger.info('Email queue processor started successfully'); + } else { + logger.info('Email queue processor: Already running'); + } +} + +function stopEmailQueueProcessor() { + if (emailQueueInterval) { + clearInterval(emailQueueInterval); + emailQueueInterval = null; + logger.info('Email queue processor stopped'); + } +} + +// Initialize on module load - DISABLED for production startup +// This will be called from server.js after database is ready +// initializeTransporter().then(() => { +// startEmailQueueProcessor(); +// }); + +module.exports = { + initializeTransporter, + startEmailQueueProcessor, + sendTemplateEmail, + processEmailQueue, + queueEmail, + stopEmailQueueProcessor, + testEmailConnection +}; \ No newline at end of file diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js new file mode 100644 index 0000000..7ec83b5 --- /dev/null +++ b/backend/src/services/emailService.js @@ -0,0 +1,65 @@ +const nodemailer = require('nodemailer'); +const { db } = require('../database/db'); +const { emailTemplates } = require('./emailTemplates'); +const logger = require('../utils/logger'); + +// Create transporter +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT, + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } +}); + +async function sendEmail(to, type, data) { + try { + const template = emailTemplates[type](data); + + const info = await transporter.sendMail({ + from: process.env.EMAIL_FROM, + to: to, + subject: template.subject, + html: template.html, + text: template.text + }); + + logger.info(`Email sent: ${info.messageId}`); + return info; + } catch (error) { + logger.error('Error sending email:', error); + throw error; + } +} + +// Process email queue +async function processEmailQueue() { + const pendingEmails = await db('email_queue') + .where('status', 'pending') + .where('retry_count', '<', 3) + .limit(10); + + for (const email of pendingEmails) { + try { + const emailData = JSON.parse(email.email_data); + await sendEmail(email.recipient_email, email.email_type, emailData); + + await db('email_queue').where('id', email.id).update({ + status: 'sent', + sent_at: new Date() + }); + } catch (error) { + await db('email_queue').where('id', email.id).update({ + retry_count: email.retry_count + 1, + error_message: error.message + }); + } + } +} + +// Start email queue processor +setInterval(processEmailQueue, 60000); // Process every minute + +module.exports = { sendEmail, processEmailQueue }; diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js new file mode 100644 index 0000000..4501f67 --- /dev/null +++ b/backend/src/services/expirationChecker.js @@ -0,0 +1,101 @@ +const cron = require('node-cron'); +const { db } = require('../database/db'); +const { archiveEvent } = require('./archiveService'); +const { queueEmail } = require('./emailProcessor'); +const logger = require('../utils/logger'); +const { formatDate } = require('../utils/dateFormatter'); +const { formatBoolean } = require('../utils/dbCompat'); + +function startExpirationChecker() { + // Check every hour for expired events and warnings + cron.schedule('0 * * * *', async () => { + await checkExpirations(); + }); + + logger.info('Expiration checker started'); +} + +async function checkExpirations() { + try { + const now = new Date(); + const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now + + // Check for events needing warning emails + const eventsNeedingWarning = await db('events') + .where('is_active', formatBoolean(true)) + .where('is_archived', formatBoolean(false)) + .where('expires_at', '<=', warningDate) + .where('expires_at', '>', now); + + for (const event of eventsNeedingWarning) { + // Check if warning email already sent + const existingWarning = await db('email_queue') + .where('event_id', event.id) + .where('email_type', 'expiration_warning') + .first(); + + if (!existingWarning) { + await queueExpirationWarning(event); + } + } + + // Check for expired events + const expiredEvents = await db('events') + .where('is_active', formatBoolean(true)) + .where('is_archived', formatBoolean(false)) + .where('expires_at', '<=', now); + + for (const event of expiredEvents) { + await handleExpiredEvent(event); + } + + } catch (error) { + logger.error('Error checking expirations:', error); + } +} + +async function queueExpirationWarning(event) { + const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24)); + + // Determine language based on email domain + const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en'; + + // Queue email to host + await queueEmail(event.id, event.host_email, 'expiration_warning', { + host_name: event.host_name || event.host_email.split('@')[0], + event_name: event.event_name, + days_remaining: daysRemaining.toString(), + expiration_date: await formatDate(event.expires_at, emailLang), + gallery_link: event.share_link + }); + + logger.info(`Queued expiration warning for event ${event.slug}`); +} + +async function handleExpiredEvent(event) { + try { + // Mark as inactive + await db('events').where('id', event.id).update({ is_active: formatBoolean(false) }); + + // Queue expiration emails + await queueEmail(event.id, event.host_email, 'gallery_expired', { + event_name: event.event_name, + admin_email: event.admin_email + }); + + // Also notify admin + await queueEmail(event.id, event.admin_email, 'gallery_expired', { + event_name: event.event_name, + admin_email: event.admin_email + }); + + // Start archiving process + await archiveEvent(event); + + logger.info(`Handled expiration for event ${event.slug}`); + } catch (error) { + logger.error(`Error handling expired event ${event.slug}:`, error); + } +} + +module.exports = { startExpirationChecker }; diff --git a/backend/src/services/fileWatcher.js b/backend/src/services/fileWatcher.js new file mode 100644 index 0000000..6940cde --- /dev/null +++ b/backend/src/services/fileWatcher.js @@ -0,0 +1,105 @@ +const chokidar = require('chokidar'); +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { generateThumbnail } = require('./imageProcessor'); +const logger = require('../utils/logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +const WATCH_PATH = () => path.join(getStoragePath(), 'events/active'); + +function startFileWatcher() { + const watcher = chokidar.watch(WATCH_PATH(), { + ignored: /(^|[\/\\])\../, // ignore dotfiles + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 2000, + pollInterval: 100 + } + }); + + watcher + .on('add', async (filePath) => { + try { + await processNewPhoto(filePath); + } catch (error) { + logger.error('Error processing new photo:', error); + } + }) + .on('unlink', async (filePath) => { + try { + await removePhoto(filePath); + } catch (error) { + logger.error('Error removing photo:', error); + } + }); + + logger.info('File watcher started'); +} + +async function processNewPhoto(filePath) { + const relativePath = path.relative(WATCH_PATH(), filePath); + const pathParts = relativePath.split(path.sep); + + if (pathParts.length < 2) return; // Not in correct folder structure + + const eventSlug = pathParts[0]; + const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual'; + + // Check if this is an image file + const ext = path.extname(filePath).toLowerCase(); + if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return; + + // Skip temporary upload files + const filename = path.basename(filePath); + if (filename.startsWith('temp_')) { + logger.debug(`Skipping temporary upload file: ${filename}`); + return; + } + + // Find the event + const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first(); + if (!event) return; + + // Get file stats + const stats = await fs.stat(filePath); + + // Generate thumbnail + const thumbnailPath = await generateThumbnail(filePath); + + // Calculate relative thumbnail path + const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root + + // Check if photo already exists + const existingPhoto = await db('photos') + .where({ event_id: event.id, filename: path.basename(filePath) }) + .first(); + + if (!existingPhoto) { + // Add to database + await db('photos').insert({ + event_id: event.id, + filename: path.basename(filePath), + path: relativePath, + thumbnail_path: relativeThumbPath, + type: photoType, + size_bytes: stats.size + }); + + logger.info(`Added new photo: ${relativePath}`); + } else { + logger.debug(`Photo already exists: ${relativePath}`); + } +} + +async function removePhoto(filePath) { + const relativePath = path.relative(WATCH_PATH(), filePath); + + // Remove from database + await db('photos').where({ path: relativePath }).delete(); + + logger.info(`Removed photo: ${relativePath}`); +} + +module.exports = { startFileWatcher }; diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js new file mode 100644 index 0000000..26f865d --- /dev/null +++ b/backend/src/services/imageProcessor.js @@ -0,0 +1,134 @@ +const sharp = require('sharp'); +const path = require('path'); +const fs = require('fs').promises; +const logger = require('../utils/logger'); + +// Configure sharp for better memory management with large batches +sharp.cache(false); // Disable cache to prevent memory buildup +sharp.concurrency(2); // Limit concurrent operations + +const THUMBNAIL_WIDTH = 300; +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails'); + +async function generateThumbnail(imagePath, options = {}) { + const filename = path.basename(imagePath); + const thumbnailFilename = `thumb_${filename}`; + const thumbnailDir = getThumbnailPath(); + const thumbnailPath = path.join(thumbnailDir, thumbnailFilename); + + // Ensure thumbnail directory exists + await fs.mkdir(thumbnailDir, { recursive: true }); + + // Check if we need to regenerate (for broken thumbnails) + if (options.regenerate) { + try { + await fs.unlink(thumbnailPath); + logger.info(`Deleted broken thumbnail: ${thumbnailPath}`); + } catch (err) { + // File might not exist, that's okay + } + } + + try { + // First, verify the source image is complete and valid + const metadata = await sharp(imagePath).metadata(); + + if (!metadata.width || !metadata.height) { + throw new Error('Invalid image metadata - file may be incomplete'); + } + + // Generate thumbnail with memory-efficient settings and error handling + await sharp(imagePath, { + limitInputPixels: 268402689, // ~16k x 16k max + sequentialRead: true, // More memory efficient for large images + failOnError: false // Don't fail on minor issues + }) + .resize(THUMBNAIL_WIDTH, null, { + withoutEnlargement: true, + fit: 'inside' + }) + .jpeg({ + quality: 80, + progressive: true, // Progressive JPEG for better loading + mozjpeg: true // Better compression + }) + .toFile(thumbnailPath); + + // Verify the thumbnail was created successfully + const stats = await fs.stat(thumbnailPath); + if (stats.size === 0) { + throw new Error('Generated thumbnail is empty'); + } + + return path.relative(getStoragePath(), thumbnailPath); + } catch (error) { + logger.error(`Failed to generate thumbnail for ${filename}:`, error.message); + + // Clean up any partially created file + try { + await fs.unlink(thumbnailPath); + } catch (unlinkErr) { + // Ignore unlink errors + } + + // Return null if thumbnail generation fails, don't fail the whole upload + return null; + } +} + +/** + * Check if a thumbnail exists and is valid + */ +async function isThumbnailValid(thumbnailPath) { + try { + const fullPath = path.join(getStoragePath(), thumbnailPath); + const stats = await fs.stat(fullPath); + + // Check if file exists and has content + if (stats.size === 0) { + return false; + } + + // Try to read metadata to ensure it's a valid image + await sharp(fullPath).metadata(); + return true; + } catch (error) { + return false; + } +} + +/** + * Regenerate thumbnail if it's broken or missing + */ +async function ensureThumbnail(photo) { + const storagePath = getStoragePath(); + const originalPath = path.join(storagePath, 'events/active', photo.path); + + // Check if thumbnail exists and is valid + if (photo.thumbnail_path) { + const isValid = await isThumbnailValid(photo.thumbnail_path); + if (isValid) { + return photo.thumbnail_path; + } + logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`); + } + + // Generate new thumbnail + const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true }); + + if (newThumbnailPath) { + // Update database with new thumbnail path + const { db } = require('../database/db'); + await db('photos') + .where({ id: photo.id }) + .update({ thumbnail_path: newThumbnailPath }); + + logger.info(`Regenerated thumbnail for photo ${photo.id}`); + return newThumbnailPath; + } + + return null; +} + +module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail }; diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js new file mode 100644 index 0000000..5861d57 --- /dev/null +++ b/backend/src/services/photoProcessor.js @@ -0,0 +1,112 @@ +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); +const { generateThumbnail } = require('./imageProcessor'); +const { generatePhotoFilename } = require('../utils/filenameSanitizer'); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) { + const uploadedPhotos = []; + + // Get event details + const event = await db('events').where({ id: eventId }).first(); + if (!event) { + throw new Error('Event not found'); + } + + // Process each file + for (const file of files) { + const trx = await db.transaction(); + + try { + // Get category info if provided + let category = null; + let counter = 1; + const parsedCategoryId = categoryId ? parseInt(categoryId) : null; + + if (parsedCategoryId) { + // Get category and update counter + category = await trx('photo_categories') + .where({ id: parsedCategoryId }) + .first(); + + if (category) { + counter = (category.photo_counter || 0) + 1; + await trx('photo_categories') + .where({ id: parsedCategoryId }) + .update({ photo_counter: counter }); + } + } else { + // For uncategorized photos, count existing uncategorized photos + const uncategorizedCount = await trx('photos') + .where({ event_id: eventId }) + .whereNull('category_id') + .count('id as count') + .first(); + + counter = (uncategorizedCount.count || 0) + 1; + } + + // Generate new filename + const extension = path.extname(file.originalname); + const newFilename = generatePhotoFilename( + event.event_name, + category ? category.name : 'uncategorized', + counter, + extension + ); + + // Move file to event folder + const destPath = path.join(getStoragePath(), 'events/active', event.slug); + await fs.mkdir(destPath, { recursive: true }); + + const newPath = path.join(destPath, newFilename); + // Use copyFile and unlink instead of rename to avoid cross-device issues + await fs.copyFile(file.path, newPath); + await fs.unlink(file.path); + + // Generate thumbnail + const thumbnailPath = await generateThumbnail(newPath); + + // Calculate relative paths + const storagePath = getStoragePath(); + const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath); + const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root + + // Add to database with uploaded_by field + const [photoId] = await trx('photos').insert({ + event_id: eventId, + filename: newFilename, + path: relativePath, + thumbnail_path: relativeThumbPath, + category_id: parsedCategoryId || null, + type: 'individual', + size_bytes: file.size, + uploaded_by: uploadedBy + }); + + // Commit transaction + await trx.commit(); + + uploadedPhotos.push({ + id: photoId, + filename: newFilename, + size: file.size, + category_id: parsedCategoryId || null, + uploaded_by: uploadedBy + }); + } catch (error) { + console.error(`Error processing file ${file.originalname}:`, error); + if (trx) await trx.rollback(); + // Continue with other files + } + } + + return uploadedPhotos; +} + +module.exports = { + processUploadedPhotos +}; \ No newline at end of file diff --git a/backend/src/services/rateLimitService.js b/backend/src/services/rateLimitService.js new file mode 100644 index 0000000..d08771a --- /dev/null +++ b/backend/src/services/rateLimitService.js @@ -0,0 +1,283 @@ +const rateLimit = require('express-rate-limit'); +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +// Cache for rate limit settings +let settingsCache = null; +let cacheExpiry = 0; +const CACHE_DURATION = 60000; // 1 minute cache + +/** + * Get rate limit settings from database with caching + */ +async function getRateLimitSettings() { + try { + // Check cache + if (settingsCache && Date.now() < cacheExpiry) { + return settingsCache; + } + + // Fetch from database + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'rate_limit_enabled', + 'rate_limit_window_minutes', + 'rate_limit_max_requests', + 'rate_limit_auth_max_requests', + 'rate_limit_skip_authenticated', + 'rate_limit_public_endpoints_only' + ]); + + // Parse settings into object + const config = { + enabled: true, + windowMinutes: 15, + maxRequests: 100, + authMaxRequests: 5, + skipAuthenticated: true, + publicEndpointsOnly: false + }; + + settings.forEach(setting => { + const value = JSON.parse(setting.setting_value); + switch (setting.setting_key) { + case 'rate_limit_enabled': + config.enabled = value; + break; + case 'rate_limit_window_minutes': + config.windowMinutes = value; + break; + case 'rate_limit_max_requests': + config.maxRequests = value; + break; + case 'rate_limit_auth_max_requests': + config.authMaxRequests = value; + break; + case 'rate_limit_skip_authenticated': + config.skipAuthenticated = value; + break; + case 'rate_limit_public_endpoints_only': + config.publicEndpointsOnly = value; + break; + } + }); + + // Update cache + settingsCache = config; + cacheExpiry = Date.now() + CACHE_DURATION; + + return config; + } catch (error) { + logger.error('Failed to fetch rate limit settings:', error); + // Return defaults on error + return { + enabled: true, + windowMinutes: 15, + maxRequests: 100, + authMaxRequests: 5, + skipAuthenticated: true, + publicEndpointsOnly: false + }; + } +} + +/** + * Clear settings cache (call when settings are updated) + */ +function clearSettingsCache() { + settingsCache = null; + cacheExpiry = 0; +} + +/** + * Check if request has valid authentication + */ +function isAuthenticated(req) { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + const token = authHeader.substring(7); + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Check if token is valid + if (!decoded || typeof decoded !== 'object') { + return false; + } + + // Valid token found - check type + req.tokenType = decoded.type; // 'admin' or 'gallery' + req.tokenPayload = decoded; + + return true; + } catch (error) { + return false; + } +} + +/** + * Determine if rate limiting should be applied to this request + */ +function shouldSkipRateLimit(req, config) { + // If rate limiting is disabled globally + if (!config.enabled) { + return true; + } + + // Never skip rate limiting for auth endpoints + const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/); + if (isAuthEndpoint) { + return false; + } + + // Check if we should skip authenticated requests + if (config.skipAuthenticated && isAuthenticated(req)) { + return true; + } + + // Check if we only rate limit public endpoints + if (config.publicEndpointsOnly) { + const isPublicEndpoint = req.path.startsWith('/api/public/') || + req.path.startsWith('/api/gallery/') || + isAuthEndpoint; + return !isPublicEndpoint; + } + + return false; +} + +/** + * Create dynamic rate limiter + */ +async function createRateLimiter() { + const config = await getRateLimitSettings(); + + return rateLimit({ + windowMs: config.windowMinutes * 60 * 1000, + max: async (req) => { + // Refresh config for each request + const currentConfig = await getRateLimitSettings(); + + // Different limits for auth endpoints + const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/); + return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests; + }, + keyGenerator: (req) => { + // Use correct client IP when behind proxy + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + }, + skip: async (req) => { + const currentConfig = await getRateLimitSettings(); + return shouldSkipRateLimit(req, currentConfig); + }, + handler: (req, res) => { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + + // Enhanced logging for production analysis + logger.warn('Rate limit exceeded', { + ip: clientIp, + path: req.path, + method: req.method, + authenticated: isAuthenticated(req), + tokenType: req.tokenType, + userAgent: req.headers['user-agent'], + referer: req.headers['referer'], + origin: req.headers['origin'], + timestamp: new Date().toISOString(), + headers: { + 'x-forwarded-for': req.headers['x-forwarded-for'], + 'x-real-ip': req.headers['x-real-ip'] + }, + requestUrl: req.originalUrl, + rateLimitInfo: { + limit: req.rateLimit?.limit, + current: req.rateLimit?.current, + remaining: req.rateLimit?.remaining, + resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null + } + }); + + res.status(429).json({ + error: 'Too many requests, please try again later.', + retryAfter: res.getHeader('Retry-After') + }); + }, + standardHeaders: true, // Return rate limit info in headers + legacyHeaders: false, // Disable X-RateLimit headers + }); +} + +/** + * Create auth-specific rate limiter + */ +async function createAuthRateLimiter() { + const config = await getRateLimitSettings(); + + return rateLimit({ + windowMs: config.windowMinutes * 60 * 1000, + max: config.authMaxRequests, + keyGenerator: (req) => { + // Use correct client IP when behind proxy + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + }, + skip: async () => { + const currentConfig = await getRateLimitSettings(); + return !currentConfig.enabled; + }, + handler: (req, res) => { + const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.ip; + + // Enhanced logging for auth failures + logger.warn('Auth rate limit exceeded', { + ip: clientIp, + path: req.path, + method: req.method, + userAgent: req.headers['user-agent'], + timestamp: new Date().toISOString(), + headers: { + 'x-forwarded-for': req.headers['x-forwarded-for'], + 'x-real-ip': req.headers['x-real-ip' + }, + requestUrl: req.originalUrl, + authType: req.path.includes('admin') ? 'admin' : 'gallery', + rateLimitInfo: { + limit: req.rateLimit?.limit, + current: req.rateLimit?.current, + remaining: req.rateLimit?.remaining, + resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null + } + }); + + res.status(429).json({ + error: 'Too many authentication attempts, please try again later.', + retryAfter: res.getHeader('Retry-After') + }); + }, + standardHeaders: true, + legacyHeaders: false, + }); +} + +module.exports = { + getRateLimitSettings, + clearSettingsCache, + createRateLimiter, + createAuthRateLimiter, + isAuthenticated, + shouldSkipRateLimit +}; \ No newline at end of file diff --git a/backend/src/services/recaptcha.js b/backend/src/services/recaptcha.js new file mode 100644 index 0000000..05d49f5 --- /dev/null +++ b/backend/src/services/recaptcha.js @@ -0,0 +1,58 @@ +const axios = require('axios'); +const { db } = require('../database/db'); + +async function verifyRecaptcha(token) { + // Check if reCAPTCHA is enabled + const settings = await db('app_settings') + .whereIn('setting_key', ['security_enable_recaptcha', 'security_recaptcha_secret_key']) + .select('setting_key', 'setting_value'); + + const settingsMap = {}; + settings.forEach(setting => { + try { + settingsMap[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + settingsMap[setting.setting_key] = setting.setting_value; + } + }); + + const isEnabled = settingsMap.security_enable_recaptcha === true || + settingsMap.security_enable_recaptcha === 'true'; + const secretKey = settingsMap.security_recaptcha_secret_key; + + // If reCAPTCHA is not enabled, always return true + if (!isEnabled) { + return true; + } + + // If enabled but no token provided, fail + if (!token) { + return false; + } + + // If no secret key configured, log warning but pass + if (!secretKey) { + console.warn('reCAPTCHA enabled but no secret key configured'); + return true; + } + + try { + const response = await axios.post( + 'https://www.google.com/recaptcha/api/siteverify', + null, + { + params: { + secret: secretKey, + response: token + } + } + ); + + return response.data.success === true; + } catch (error) { + console.error('reCAPTCHA verification error:', error); + return false; + } +} + +module.exports = { verifyRecaptcha }; \ No newline at end of file diff --git a/backend/src/services/watermarkService.js b/backend/src/services/watermarkService.js new file mode 100644 index 0000000..8c5de36 --- /dev/null +++ b/backend/src/services/watermarkService.js @@ -0,0 +1,226 @@ +const sharp = require('sharp'); +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../database/db'); + +class WatermarkService { + constructor() { + this.cache = new Map(); + this.cacheMaxAge = 3600000; // 1 hour in milliseconds + } + + /** + * Get watermark settings from database + */ + async getWatermarkSettings() { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'branding_watermark_enabled', + 'branding_watermark_logo_path', + 'branding_watermark_position', + 'branding_watermark_opacity', + 'branding_watermark_size', + 'branding_company_name' + ]) + .select('setting_key', 'setting_value'); + + const settingsObj = {}; + settings.forEach(setting => { + try { + settingsObj[setting.setting_key] = JSON.parse(setting.setting_value); + } catch (e) { + settingsObj[setting.setting_key] = setting.setting_value; + } + }); + + return { + enabled: settingsObj.branding_watermark_enabled || false, + logoPath: settingsObj.branding_watermark_logo_path || null, + position: settingsObj.branding_watermark_position || 'bottom-right', + opacity: parseInt(settingsObj.branding_watermark_opacity || 50), + size: parseInt(settingsObj.branding_watermark_size || 15), + companyName: settingsObj.branding_company_name || 'Photo Gallery' + }; + } catch (error) { + console.error('Error fetching watermark settings:', error); + return null; + } + } + + /** + * Calculate position coordinates based on position string + */ + getPositionCoordinates(imageWidth, imageHeight, watermarkWidth, watermarkHeight, position) { + const padding = 20; + let left, top; + + switch (position) { + case 'top-left': + left = padding; + top = padding; + break; + case 'top-right': + left = imageWidth - watermarkWidth - padding; + top = padding; + break; + case 'bottom-left': + left = padding; + top = imageHeight - watermarkHeight - padding; + break; + case 'bottom-right': + left = imageWidth - watermarkWidth - padding; + top = imageHeight - watermarkHeight - padding; + break; + case 'center': + left = Math.floor((imageWidth - watermarkWidth) / 2); + top = Math.floor((imageHeight - watermarkHeight) / 2); + break; + default: + // Default to bottom-right + left = imageWidth - watermarkWidth - padding; + top = imageHeight - watermarkHeight - padding; + } + + return { left: Math.max(0, left), top: Math.max(0, top) }; + } + + /** + * Apply watermark to an image + */ + async applyWatermark(imagePath, settings) { + try { + if (!settings || !settings.enabled) { + // Return original image if watermarking is disabled + return await fs.readFile(imagePath); + } + + // Check cache first + const cacheKey = `${imagePath}_${JSON.stringify(settings)}`; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < this.cacheMaxAge) { + return cached.buffer; + } + + // Load the main image + const image = sharp(imagePath); + const metadata = await image.metadata(); + + let watermarkBuffer; + let watermarkMetadata; + + // Try to use logo watermark first + if (settings.logoPath) { + try { + const watermarkImage = sharp(settings.logoPath); + watermarkMetadata = await watermarkImage.metadata(); + + // Calculate watermark size based on percentage of main image + const scaleFactor = settings.size / 100; + const targetWidth = Math.floor(metadata.width * scaleFactor); + const targetHeight = Math.floor(watermarkMetadata.height * (targetWidth / watermarkMetadata.width)); + + // Resize watermark and apply opacity + watermarkBuffer = await watermarkImage + .resize(targetWidth, targetHeight, { fit: 'inside' }) + .composite([{ + input: Buffer.from([255, 255, 255, Math.floor(255 * (settings.opacity / 100))]), + raw: { + width: 1, + height: 1, + channels: 4 + }, + tile: true, + blend: 'dest-in' + }]) + .toBuffer(); + + watermarkMetadata = { width: targetWidth, height: targetHeight }; + } catch (error) { + console.error('Error processing watermark logo:', error); + watermarkBuffer = null; + } + } + + // If no logo or logo failed, create text watermark + if (!watermarkBuffer) { + const fontSize = Math.max(16, Math.floor(metadata.width * 0.03)); + const padding = 10; + + // Create SVG text watermark + const svg = ` + + + + ${settings.companyName} + + + `; + + watermarkBuffer = Buffer.from(svg); + watermarkMetadata = { + width: settings.companyName.length * fontSize * 0.6 + padding * 2, + height: fontSize + padding * 2 + }; + } + + // Calculate position + const position = this.getPositionCoordinates( + metadata.width, + metadata.height, + watermarkMetadata.width, + watermarkMetadata.height, + settings.position + ); + + // Apply watermark + const watermarkedBuffer = await image + .composite([{ + input: watermarkBuffer, + top: position.top, + left: position.left + }]) + .toBuffer(); + + // Cache the result + this.cache.set(cacheKey, { + buffer: watermarkedBuffer, + timestamp: Date.now() + }); + + // Clean old cache entries + this.cleanCache(); + + return watermarkedBuffer; + } catch (error) { + console.error('Error applying watermark:', error); + // Return original image on error + return await fs.readFile(imagePath); + } + } + + /** + * Clean old cache entries + */ + cleanCache() { + const now = Date.now(); + for (const [key, value] of this.cache.entries()) { + if (now - value.timestamp > this.cacheMaxAge) { + this.cache.delete(key); + } + } + } + + /** + * Clear entire cache + */ + clearCache() { + this.cache.clear(); + } +} + +module.exports = new WatermarkService(); \ No newline at end of file diff --git a/backend/src/utils/authSecurity.js b/backend/src/utils/authSecurity.js new file mode 100644 index 0000000..57a2ab6 --- /dev/null +++ b/backend/src/utils/authSecurity.js @@ -0,0 +1,190 @@ +/** + * Authentication Security Utilities + * Provides enhanced security features for authentication + */ + +const { db } = require('../database/db'); +const { formatBoolean } = require('./dbCompat'); +const logger = require('./logger'); + +// Configuration constants +const MAX_LOGIN_ATTEMPTS = 5; +const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds +const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts + +/** + * Track failed login attempt + * @param {string} identifier - Username or email + * @param {string} ipAddress - IP address of the attempt + * @param {string} userAgent - User agent string + */ +async function trackFailedAttempt(identifier, ipAddress, userAgent) { + try { + await db('login_attempts').insert({ + identifier, + ip_address: ipAddress, + user_agent: userAgent, + attempt_time: new Date().toISOString(), + success: false + }); + + // Log security event + logger.warn('Failed login attempt', { + identifier, + ipAddress, + userAgent, + timestamp: new Date().toISOString() + }); + } catch (error) { + logger.error('Error tracking failed login attempt:', error); + } +} + +/** + * Track successful login + * @param {string} identifier - Username or email + * @param {string} ipAddress - IP address + * @param {string} userAgent - User agent string + */ +async function trackSuccessfulLogin(identifier, ipAddress, userAgent) { + try { + await db('login_attempts').insert({ + identifier, + ip_address: ipAddress, + user_agent: userAgent, + attempt_time: new Date().toISOString(), + success: true + }); + + // Clear old failed attempts for this user + const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW); + await db('login_attempts') + .where('identifier', identifier) + .where('success', formatBoolean(false)) + .where('attempt_time', '<', cutoffTime.toISOString()) + .delete(); + } catch (error) { + logger.error('Error tracking successful login:', error); + } +} + +/** + * Check if account is locked due to too many failed attempts + * @param {string} identifier - Username or email + * @returns {Promise<{isLocked: boolean, remainingTime?: number}>} + */ +async function checkAccountLockout(identifier) { + try { + const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW); + + // Get recent failed attempts + const failedAttempts = await db('login_attempts') + .where('identifier', identifier) + .where('success', formatBoolean(false)) + .where('attempt_time', '>=', recentWindow.toISOString()) + .orderBy('attempt_time', 'desc') + .limit(MAX_LOGIN_ATTEMPTS); + + if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) { + // Check if still within lockout period + const oldestAttempt = failedAttempts[failedAttempts.length - 1]; + const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION; + const now = Date.now(); + + if (now < lockoutEnd) { + return { + isLocked: true, + remainingTime: Math.ceil((lockoutEnd - now) / 1000) // seconds + }; + } + } + + return { isLocked: false }; + } catch (error) { + logger.error('Error checking account lockout:', error); + return { isLocked: false }; // Fail open to avoid locking users out due to errors + } +} + +/** + * Check for suspicious login patterns + * @param {string} identifier - Username or email + * @param {string} ipAddress - Current IP address + * @returns {Promise} - True if suspicious + */ +async function checkSuspiciousActivity(identifier, ipAddress) { + try { + // Check for rapid attempts from different IPs + const recentWindow = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes + + const recentAttempts = await db('login_attempts') + .where('identifier', identifier) + .where('attempt_time', '>=', recentWindow.toISOString()) + .select('ip_address') + .distinct('ip_address'); + + // If more than 3 different IPs in 5 minutes, it's suspicious + if (recentAttempts.length > 3) { + logger.warn('Suspicious login activity detected', { + identifier, + uniqueIPs: recentAttempts.length, + currentIP: ipAddress + }); + return true; + } + + return false; + } catch (error) { + logger.error('Error checking suspicious activity:', error); + return false; + } +} + +/** + * Get generic error message to prevent user enumeration + * @returns {string} + */ +function getGenericAuthError() { + return 'Invalid credentials'; +} + +/** + * Clean up old login attempts (should be run periodically) + */ +async function cleanupOldAttempts() { + try { + const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days + + const deleted = await db('login_attempts') + .where('attempt_time', '<', cutoffDate.toISOString()) + .delete(); + + if (deleted > 0) { + logger.info(`Cleaned up ${deleted} old login attempts`); + } + } catch (error) { + logger.error('Error cleaning up login attempts:', error); + } +} + +/** + * Initialize cleanup job + */ +function initializeCleanupJob() { + // Run cleanup every 24 hours + setInterval(cleanupOldAttempts, 24 * 60 * 60 * 1000); + + // Run initial cleanup + cleanupOldAttempts(); +} + +module.exports = { + trackFailedAttempt, + trackSuccessfulLogin, + checkAccountLockout, + checkSuspiciousActivity, + getGenericAuthError, + initializeCleanupJob, + MAX_LOGIN_ATTEMPTS, + LOCKOUT_DURATION +}; \ No newline at end of file diff --git a/backend/src/utils/cleanupTempUploads.js b/backend/src/utils/cleanupTempUploads.js new file mode 100644 index 0000000..2e3ea55 --- /dev/null +++ b/backend/src/utils/cleanupTempUploads.js @@ -0,0 +1,78 @@ +const path = require('path'); +const fs = require('fs').promises; +const logger = require('./logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +/** + * Clean up old temporary upload directories + * Removes temp directories older than 1 hour + */ +async function cleanupTempUploads() { + const tempPath = path.join(getStoragePath(), 'temp'); + + try { + // Ensure temp directory exists + await fs.mkdir(tempPath, { recursive: true }); + + // Read all items in temp directory + const items = await fs.readdir(tempPath); + + let cleanedCount = 0; + const oneHourAgo = Date.now() - (60 * 60 * 1000); // 1 hour + + for (const item of items) { + const itemPath = path.join(tempPath, item); + + try { + const stats = await fs.stat(itemPath); + + // Only process directories that match our upload pattern + if (stats.isDirectory() && item.startsWith('upload_')) { + // Extract timestamp from directory name + const parts = item.split('_'); + if (parts.length >= 2) { + const timestamp = parseInt(parts[1]); + + // Remove if older than 1 hour + if (!isNaN(timestamp) && timestamp < oneHourAgo) { + logger.info(`Cleaning up old temp upload directory: ${item}`); + await fs.rm(itemPath, { recursive: true, force: true }); + cleanedCount++; + } + } + } + } catch (error) { + logger.error(`Error processing temp item ${item}:`, error.message); + } + } + + if (cleanedCount > 0) { + logger.info(`Cleaned up ${cleanedCount} old temp upload directories`); + } + + } catch (error) { + logger.error('Error during temp upload cleanup:', error); + } +} + +/** + * Start periodic cleanup of temp uploads + * Runs every hour + */ +function startTempUploadCleanup() { + // Run immediately on startup + cleanupTempUploads(); + + // Then run every hour + setInterval(() => { + cleanupTempUploads(); + }, 60 * 60 * 1000); // 1 hour + + logger.info('Temp upload cleanup service started'); +} + +module.exports = { + cleanupTempUploads, + startTempUploadCleanup +}; \ No newline at end of file diff --git a/backend/src/utils/dateFormatter.js b/backend/src/utils/dateFormatter.js new file mode 100644 index 0000000..bc7ff07 --- /dev/null +++ b/backend/src/utils/dateFormatter.js @@ -0,0 +1,100 @@ +const { db } = require('../database/db'); + +// Default date format settings +const DEFAULT_FORMAT = { + format: 'DD/MM/YYYY', + locale: 'en-GB' +}; + +// Format date based on system settings +async function formatDate(date, language = 'en') { + try { + // Get date format setting from database + const setting = await db('app_settings').where('setting_key', 'general_date_format').first(); + let dateConfig = DEFAULT_FORMAT; + + if (setting && setting.setting_value) { + // Handle both string and object values + if (typeof setting.setting_value === 'string') { + try { + dateConfig = JSON.parse(setting.setting_value); + } catch (e) { + console.warn('Failed to parse date format setting:', e.message); + dateConfig = DEFAULT_FORMAT; + } + } else { + dateConfig = setting.setting_value; + } + } + + // Ensure proper date parsing + let dateObj; + if (date instanceof Date) { + dateObj = date; + } else if (typeof date === 'string') { + // For date strings like "2025-07-16", parse as local date to avoid timezone issues + if (date.match(/^\d{4}-\d{2}-\d{2}$/)) { + // Parse YYYY-MM-DD format as local date + const [year, month, day] = date.split('-').map(num => parseInt(num, 10)); + dateObj = new Date(year, month - 1, day); + } else { + dateObj = new Date(date); + } + } else { + dateObj = new Date(date); + } + + // Check if date is valid + if (isNaN(dateObj.getTime())) { + console.error('Invalid date provided to formatDate:', date); + throw new Error('Invalid date'); + } + + // Use appropriate locale based on language + let locale = dateConfig.locale || 'en-GB'; + if (language === 'de') { + locale = 'de-DE'; + } else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') { + locale = 'en-US'; + } + + // Format based on the configured format + switch (dateConfig.format) { + case 'MM/DD/YYYY': + return dateObj.toLocaleDateString(locale, { + month: '2-digit', + day: '2-digit', + year: 'numeric' + }); + case 'DD/MM/YYYY': + return dateObj.toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + case 'YYYY-MM-DD': + return dateObj.toISOString().split('T')[0]; + case 'DD.MM.YYYY': + return dateObj.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + default: + // Use long format as fallback + return dateObj.toLocaleDateString(locale, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } + } catch (error) { + console.error('Error formatting date:', error); + // Fallback to basic formatting + return date instanceof Date ? date.toLocaleDateString() : new Date(date).toLocaleDateString(); + } +} + +module.exports = { + formatDate +}; \ No newline at end of file diff --git a/backend/src/utils/dbCompat.js b/backend/src/utils/dbCompat.js new file mode 100644 index 0000000..2e28033 --- /dev/null +++ b/backend/src/utils/dbCompat.js @@ -0,0 +1,133 @@ +/** + * Database Compatibility Utilities + * Handles differences between PostgreSQL and SQLite + */ + +// Note: Requiring db here creates circular dependency +// db should be passed as parameter or required where needed + +/** + * Get database client type + * @returns {string} 'pg' or 'sqlite3' + */ +function getDbClient() { + return process.env.DATABASE_CLIENT || 'sqlite3'; +} + +/** + * Check if using PostgreSQL + * @returns {boolean} + */ +function isPostgreSQL() { + return getDbClient() === 'pg'; +} + +/** + * Handle insert operations that return IDs + * Works with both PostgreSQL and SQLite + * @param {object} query - Knex query builder + * @returns {Promise} The inserted ID + */ +async function insertAndGetId(query) { + const result = await query.returning('id'); + + // PostgreSQL returns array of objects [{id: 1}] + // SQLite returns array of IDs [1] + return result[0]?.id || result[0]; +} + +/** + * Format date for database compatibility + * @param {Date} date - JavaScript Date object + * @returns {string} ISO string format that works on both databases + */ +function formatDateForDB(date) { + return date.toISOString(); +} + +/** + * Add days to a date (database agnostic) + * @param {Date} date - Starting date + * @param {number} days - Number of days to add + * @returns {Date} New date + */ +function addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +/** + * Get date extraction SQL that works on both databases + * @param {object} db - Knex database instance + * @param {string} column - Column name + * @returns {object} Knex raw query + */ +function dateExtractSQL(db, column) { + if (isPostgreSQL()) { + return db.raw(`DATE(${column})`); + } else { + // SQLite uses date() function + return db.raw(`date(${column})`); + } +} + +/** + * Get database size query + * @param {object} db - Knex database instance + * @param {string} dbName - Database name + * @returns {Promise} Size in bytes + */ +async function getDatabaseSize(db, dbName) { + if (isPostgreSQL()) { + const result = await db.raw('SELECT pg_database_size(?) as size', [dbName]); + return result.rows[0]?.size || 0; + } else { + // For SQLite, check file size + const fs = require('fs').promises; + const path = require('path'); + const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../data/photo_sharing.db'); + try { + const stats = await fs.stat(dbPath); + return stats.size; + } catch (error) { + console.error('Error getting SQLite database size:', error); + return 0; + } + } +} + +/** + * Handle boolean values for database compatibility + * @param {boolean} value - Boolean value + * @returns {any} Database-appropriate boolean representation + */ +function formatBoolean(value) { + if (isPostgreSQL()) { + return value; + } else { + // SQLite stores booleans as 0/1 + return value ? 1 : 0; + } +} + +/** + * Parse boolean from database + * @param {any} value - Database boolean value + * @returns {boolean} JavaScript boolean + */ +function parseBoolean(value) { + return Boolean(value); +} + +module.exports = { + getDbClient, + isPostgreSQL, + insertAndGetId, + formatDateForDB, + addDays, + dateExtractSQL, + getDatabaseSize, + formatBoolean, + parseBoolean +}; \ No newline at end of file diff --git a/backend/src/utils/fileSecurityUtils.js b/backend/src/utils/fileSecurityUtils.js new file mode 100644 index 0000000..b7fb11d --- /dev/null +++ b/backend/src/utils/fileSecurityUtils.js @@ -0,0 +1,233 @@ +const path = require('path'); +const fs = require('fs').promises; + +/** + * Secure file security utilities to prevent path traversal and validate file types + */ + +/** + * Safely join paths and prevent directory traversal attacks + * @param {string} basePath - The base directory path + * @param {string} userPath - The user-provided path to join + * @returns {string} - Safe joined path + * @throws {Error} - If path traversal is detected + */ +function safePathJoin(basePath, userPath) { + // Normalize the base path + const normalizedBase = path.resolve(basePath); + + // Join and resolve the full path + const joinedPath = path.join(normalizedBase, userPath); + const resolvedPath = path.resolve(joinedPath); + + // Ensure the resolved path starts with the base path + if (!resolvedPath.startsWith(normalizedBase + path.sep) && resolvedPath !== normalizedBase) { + throw new Error('Path traversal attempt detected'); + } + + return resolvedPath; +} + +/** + * Validate file path to prevent directory traversal + * @param {string} filePath - The file path to validate + * @returns {boolean} - True if path is safe + */ +function isPathSafe(filePath) { + // Check for common path traversal patterns + const dangerousPatterns = [ + /\.\.[\/\\]/, // ../ or ..\ + /^[A-Za-z]:/, // Windows drive letters + /[\x00-\x1f]/ // Control characters + ]; + + return !dangerousPatterns.some(pattern => pattern.test(filePath)); +} + +/** + * Enhanced MIME type validation + */ +const ALLOWED_IMAGE_TYPES = { + 'image/jpeg': { + extensions: ['.jpg', '.jpeg'], + magicNumbers: [ + { offset: 0, bytes: [0xFF, 0xD8, 0xFF] } // JPEG + ] + }, + 'image/png': { + extensions: ['.png'], + magicNumbers: [ + { offset: 0, bytes: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A] } // PNG + ] + }, + 'image/webp': { + extensions: ['.webp'], + magicNumbers: [ + { offset: 0, bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF + { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } // WEBP + ] + }, + 'image/gif': { + extensions: ['.gif'], + magicNumbers: [ + { offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61] }, // GIF87a + { offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] } // GIF89a + ] + }, + 'image/svg+xml': { + extensions: ['.svg'], + // SVG files are XML-based text files, so we skip magic number validation + magicNumbers: null + } +}; + +/** + * Validate file type by MIME type and extension + * @param {string} filename - The filename + * @param {string} mimetype - The MIME type + * @param {string[]} allowedTypes - Array of allowed MIME types + * @returns {boolean} - True if file type is valid + */ +function validateFileType(filename, mimetype, allowedTypes) { + // Check if MIME type is allowed + if (!allowedTypes.includes(mimetype)) { + return false; + } + + // Get file extension + const ext = path.extname(filename).toLowerCase(); + + // Check if extension matches the MIME type + const typeConfig = ALLOWED_IMAGE_TYPES[mimetype]; + if (!typeConfig || !typeConfig.extensions.includes(ext)) { + return false; + } + + return true; +} + +/** + * Validate file content by checking magic numbers (file signatures) + * @param {string} filePath - Path to the file + * @param {string} expectedMimeType - Expected MIME type + * @returns {Promise} - True if file content matches expected type + */ +async function validateFileContent(filePath, expectedMimeType) { + try { + const typeConfig = ALLOWED_IMAGE_TYPES[expectedMimeType]; + if (!typeConfig) { + return false; + } + + // Skip validation for file types without magic numbers (like SVG) + if (!typeConfig.magicNumbers) { + return true; + } + + // Read the first 20 bytes of the file (enough for most magic numbers) + const buffer = Buffer.alloc(20); + const fileHandle = await fs.open(filePath, 'r'); + await fileHandle.read(buffer, 0, 20, 0); + await fileHandle.close(); + + // Check magic numbers + return typeConfig.magicNumbers.every(magic => { + for (let i = 0; i < magic.bytes.length; i++) { + if (buffer[magic.offset + i] !== magic.bytes[i]) { + return false; + } + } + return true; + }); + } catch (error) { + console.error('Error validating file content:', error); + return false; + } +} + +/** + * Get safe filename for storage + * @param {string} originalFilename - Original filename + * @returns {string} - Safe filename + */ +function getSafeFilename(originalFilename) { + const timestamp = Date.now(); + const randomString = Math.random().toString(36).substring(2, 15); + const ext = path.extname(originalFilename).toLowerCase(); + + // Validate extension + const validExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg', '.ico']; + if (!validExtensions.includes(ext)) { + throw new Error('Invalid file extension'); + } + + return `upload_${timestamp}_${randomString}${ext}`; +} + +/** + * Create a file upload validator middleware + * @param {Object} options - Validation options + * @returns {Function} - Express middleware function + */ +function createFileUploadValidator(options = {}) { + const { + allowedTypes = ['image/jpeg', 'image/png', 'image/webp'], + maxFileSize = 50 * 1024 * 1024, // 50MB default + validateContent = true + } = options; + + return async (req, res, next) => { + try { + if (!req.files || req.files.length === 0) { + return next(); + } + + for (const file of req.files) { + // Validate file type + if (!validateFileType(file.originalname, file.mimetype, allowedTypes)) { + return res.status(400).json({ + error: `Invalid file type: ${file.originalname}. Allowed types: ${allowedTypes.join(', ')}` + }); + } + + // Validate file size + if (file.size > maxFileSize) { + return res.status(400).json({ + error: `File too large: ${file.originalname}. Maximum size: ${maxFileSize / 1024 / 1024}MB` + }); + } + + // Validate file content if enabled + if (validateContent && file.path) { + const isValidContent = await validateFileContent(file.path, file.mimetype); + if (!isValidContent) { + // Remove the file if content doesn't match + try { + await fs.unlink(file.path); + } catch (err) { + console.error('Error removing invalid file:', err); + } + return res.status(400).json({ + error: `File content does not match declared type: ${file.originalname}` + }); + } + } + } + + next(); + } catch (error) { + console.error('File validation error:', error); + res.status(500).json({ error: 'File validation failed' }); + } + }; +} + +module.exports = { + safePathJoin, + isPathSafe, + validateFileType, + validateFileContent, + getSafeFilename, + createFileUploadValidator, + ALLOWED_IMAGE_TYPES +}; \ No newline at end of file diff --git a/backend/src/utils/filenameSanitizer.js b/backend/src/utils/filenameSanitizer.js new file mode 100644 index 0000000..f93232a --- /dev/null +++ b/backend/src/utils/filenameSanitizer.js @@ -0,0 +1,57 @@ +/** + * Sanitize a string to be used as a filename component + * @param {string} str - The string to sanitize + * @param {number} maxLength - Maximum length of the sanitized string + * @returns {string} - Sanitized string + */ +function sanitizeFilename(str, maxLength = 50) { + if (!str) return 'unnamed'; + + // Convert to string and trim + let sanitized = String(str).trim(); + + // Replace spaces with underscores + sanitized = sanitized.replace(/\s+/g, '_'); + + // Remove special characters except hyphens, underscores, and dots + sanitized = sanitized.replace(/[^a-zA-Z0-9_\-\.]/g, ''); + + // Remove multiple consecutive underscores or hyphens + sanitized = sanitized.replace(/[_\-]{2,}/g, '_'); + + // Remove leading/trailing underscores or hyphens + sanitized = sanitized.replace(/^[_\-]+|[_\-]+$/g, ''); + + // Limit length + if (sanitized.length > maxLength) { + sanitized = sanitized.substring(0, maxLength); + } + + // If empty after sanitization, use default + if (!sanitized) { + sanitized = 'unnamed'; + } + + return sanitized; +} + +/** + * Generate a photo filename based on event name, category, and counter + * @param {string} eventName - The event name + * @param {string} categoryName - The category name + * @param {number} counter - The photo counter + * @param {string} extension - The file extension (including dot) + * @returns {string} - Generated filename + */ +function generatePhotoFilename(eventName, categoryName, counter, extension) { + const sanitizedEvent = sanitizeFilename(eventName, 30); + const sanitizedCategory = sanitizeFilename(categoryName || 'uncategorized', 20); + const paddedCounter = String(counter).padStart(4, '0'); + + return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`; +} + +module.exports = { + sanitizeFilename, + generatePhotoFilename +}; \ No newline at end of file diff --git a/backend/src/utils/formatters.js b/backend/src/utils/formatters.js new file mode 100644 index 0000000..71d8392 --- /dev/null +++ b/backend/src/utils/formatters.js @@ -0,0 +1,40 @@ +/** + * Formatters for email content and other text transformations + */ + +/** + * Convert plain text line breaks to HTML line breaks + * @param {string} text - The text to format + * @returns {string} - Text with HTML line breaks + */ +function nl2br(text) { + if (!text) return ''; + + // Normalize line endings + text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + + // Convert newlines to
    tags + return text + .split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .join('
    '); +} + +/** + * Format welcome message for email templates + * @param {string} message - The welcome message + * @returns {string} - Formatted message for HTML emails + */ +function formatWelcomeMessage(message) { + if (!message || message.trim() === '') { + return ''; + } + + return nl2br(message); +} + +module.exports = { + nl2br, + formatWelcomeMessage +}; \ No newline at end of file diff --git a/backend/src/utils/logger.js b/backend/src/utils/logger.js new file mode 100644 index 0000000..5f24bdb --- /dev/null +++ b/backend/src/utils/logger.js @@ -0,0 +1,98 @@ +const winston = require('winston'); +const path = require('path'); +const fs = require('fs'); + +// Ensure logs directory exists +const logDir = path.join(__dirname, '../../logs'); +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }); +} + +// Custom format for production logs +const productionFormat = winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), + winston.format.errors({ stack: true }), + winston.format.json(), + winston.format.printf(info => { + // Ensure all security events are properly formatted + if (info.level === 'warn' && (info.message.includes('rate limit') || + info.message.includes('auth') || + info.message.includes('login') || + info.message.includes('JWT'))) { + return JSON.stringify({ + timestamp: info.timestamp, + level: info.level, + message: info.message, + security: true, + ...info + }); + } + return JSON.stringify(info); + }) +); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: productionFormat, + transports: [ + new winston.transports.File({ + filename: path.join(logDir, 'error.log'), + level: 'error', + maxsize: 10 * 1024 * 1024, // 10MB + maxFiles: 5, + tailable: true + }), + new winston.transports.File({ + filename: path.join(logDir, 'combined.log'), + maxsize: 50 * 1024 * 1024, // 50MB + maxFiles: 10, + tailable: true + }), + // Separate security log for authentication and rate limiting + new winston.transports.File({ + filename: path.join(logDir, 'security.log'), + level: 'warn', + maxsize: 20 * 1024 * 1024, // 20MB + maxFiles: 10, + tailable: true, + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }), + winston.format.json(), + winston.format.printf(info => { + // Only log security-related warnings + if (info.message.includes('rate limit') || + info.message.includes('auth') || + info.message.includes('login') || + info.message.includes('JWT') || + info.message.includes('lockout') || + info.message.includes('suspicious')) { + return JSON.stringify(info); + } + return null; + }) + ) + }) + ].filter(Boolean) +}); + +// Add console logging for non-production environments +if (process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.timestamp({ format: 'HH:mm:ss' }), + winston.format.printf(info => { + return `[${info.timestamp}] ${info.level}: ${info.message} ${info.stack || ''}`; + }) + ) + })); +} else { + // In production, also log to console for container environments + if (process.env.LOG_TO_CONSOLE === 'true') { + logger.add(new winston.transports.Console({ + format: productionFormat + })); + } +} + +module.exports = logger; diff --git a/backend/src/utils/passwordGenerator.js b/backend/src/utils/passwordGenerator.js new file mode 100644 index 0000000..0200766 --- /dev/null +++ b/backend/src/utils/passwordGenerator.js @@ -0,0 +1,122 @@ +const crypto = require('crypto'); + +/** + * Generate a secure random password + * @param {number} length - Password length (default: 16) + * @returns {string} Generated password + */ +function generateSecurePassword(length = 16) { + const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-=[]{}|;:,.<>?'; + let password = ''; + + // Ensure at least one of each required character type + const lowercase = 'abcdefghijklmnopqrstuvwxyz'; + const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const numbers = '0123456789'; + const special = '!@#$%^&*()_+-=[]{}|;:,.<>?'; + + // Add one of each required type + password += lowercase[crypto.randomInt(lowercase.length)]; + password += uppercase[crypto.randomInt(uppercase.length)]; + password += numbers[crypto.randomInt(numbers.length)]; + password += special[crypto.randomInt(special.length)]; + + // Fill the rest randomly + for (let i = password.length; i < length; i++) { + password += charset[crypto.randomInt(charset.length)]; + } + + // Shuffle the password + return password.split('').sort(() => crypto.randomInt(3) - 1).join(''); +} + +/** + * Generate a human-readable password using words and numbers + * @returns {string} Generated password + */ +function generateReadablePassword() { + const adjectives = [ + 'Swift', 'Bright', 'Strong', 'Happy', 'Clever', + 'Brave', 'Noble', 'Quick', 'Sharp', 'Bold' + ]; + + const nouns = [ + 'Eagle', 'Mountain', 'River', 'Thunder', 'Forest', + 'Ocean', 'Falcon', 'Dragon', 'Phoenix', 'Tiger' + ]; + + const adjective = adjectives[crypto.randomInt(adjectives.length)]; + const noun = nouns[crypto.randomInt(nouns.length)]; + const number = crypto.randomInt(1000, 9999); + const special = '!@#$%'[crypto.randomInt(5)]; + + return `${adjective}${noun}${number}${special}`; +} + +/** + * Validate password strength + * @param {string} password - Password to validate + * @returns {object} Validation result with score and messages + */ +function validatePasswordStrength(password) { + const result = { + score: 0, + messages: [], + isValid: false + }; + + // Length check + if (password.length < 8) { + result.messages.push('Password must be at least 8 characters long'); + } else if (password.length < 12) { + result.score += 1; + } else { + result.score += 2; + } + + // Character type checks + if (!/[a-z]/.test(password)) { + result.messages.push('Password must contain lowercase letters'); + } else { + result.score += 1; + } + + if (!/[A-Z]/.test(password)) { + result.messages.push('Password must contain uppercase letters'); + } else { + result.score += 1; + } + + if (!/[0-9]/.test(password)) { + result.messages.push('Password must contain numbers'); + } else { + result.score += 1; + } + + if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) { + result.messages.push('Password must contain special characters'); + } else { + result.score += 1; + } + + // Common password check + const commonPasswords = [ + 'password', 'admin123', '12345678', 'qwerty', 'abc123', + 'password123', 'admin', 'letmein', 'welcome', 'monkey' + ]; + + if (commonPasswords.includes(password.toLowerCase())) { + result.score = 0; + result.messages.push('Password is too common'); + } + + result.isValid = result.score >= 4 && result.messages.length === 0; + + return result; +} + +module.exports = { + generateSecurePassword, + generateReadablePassword, + validatePasswordStrength +}; \ No newline at end of file diff --git a/backend/src/utils/passwordValidation.js b/backend/src/utils/passwordValidation.js new file mode 100644 index 0000000..4274cfd --- /dev/null +++ b/backend/src/utils/passwordValidation.js @@ -0,0 +1,285 @@ +/** + * Password Validation and Security Utilities + * Implements strong password requirements and security checks + */ + +const zxcvbn = require('zxcvbn'); +const logger = require('./logger'); + +// Configuration +const PASSWORD_CONFIG = { + minLength: 8, // Reduced from 12 to 8 for better usability + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: false, // Made optional for gallery passwords + preventCommonPasswords: true, + minStrengthScore: 2, // Reduced from 3 to 2 (moderate strength) + bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS) || 12 // Configurable, default 12 +}; + +// Common passwords to block (extend this list) +const COMMON_PASSWORDS = [ + 'password', 'password123', 'admin123', 'welcome123', 'test123', + 'qwerty', 'abc123', '123456', 'password1', 'admin', + 'letmein', 'welcome', 'monkey', 'dragon', 'baseball' +]; + +/** + * Validate password meets security requirements + * @param {string} password - Password to validate + * @param {Object} options - Optional configuration overrides + * @returns {Object} - { valid: boolean, errors: string[], score: number, feedback: Object } + */ +function validatePassword(password, options = {}) { + const config = { ...PASSWORD_CONFIG, ...options }; + const errors = []; + + // Check if password exists + if (!password || typeof password !== 'string') { + return { + valid: false, + errors: ['Password is required'], + score: 0, + feedback: {} + }; + } + + // Check minimum length + if (password.length < config.minLength) { + errors.push(`Password must be at least ${config.minLength} characters long`); + } + + // Check uppercase requirement + if (config.requireUppercase && !/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + // Check lowercase requirement + if (config.requireLowercase && !/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + // Check number requirement + if (config.requireNumbers && !/[0-9]/.test(password)) { + errors.push('Password must contain at least one number'); + } + + // Check special character requirement + if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) { + errors.push('Password must contain at least one special character'); + } + + // Check against common passwords + if (config.preventCommonPasswords) { + const lowerPassword = password.toLowerCase(); + if (COMMON_PASSWORDS.includes(lowerPassword)) { + errors.push('This password is too common. Please choose a more unique password'); + } + } + + // Skip zxcvbn check if explicitly disabled (for gallery passwords) + if (options.skipStrengthCheck) { + return { + valid: errors.length === 0, + errors, + score: 2, // Default moderate score for gallery passwords + feedback: {} + }; + } + + // Use zxcvbn for strength analysis + const strength = zxcvbn(password); + + // Check minimum strength score + if (strength.score < config.minStrengthScore) { + errors.push('Password is too weak. Please choose a stronger password'); + } + + // Add zxcvbn suggestions + if (strength.feedback.suggestions.length > 0) { + errors.push(...strength.feedback.suggestions); + } + + return { + valid: errors.length === 0, + errors, + score: strength.score, + feedback: { + warning: strength.feedback.warning, + suggestions: strength.feedback.suggestions, + crackTime: strength.crack_times_display.offline_slow_hashing_1e4_per_second + } + }; +} + +/** + * Validate password for specific contexts (admin, gallery) + * @param {string} password - Password to validate + * @param {string} context - Context ('admin' or 'gallery') + * @param {Object} userData - Additional user data for context-aware validation + * @returns {Object} - Validation result + */ +function validatePasswordInContext(password, context, userData = {}) { + // For gallery context, use more lenient validation + if (context === 'gallery') { + // Gallery-specific validation options + const galleryOptions = { + minLength: 6, // Reduced minimum length + requireUppercase: false, // Don't require uppercase for galleries + requireLowercase: false, // Don't require lowercase for galleries + requireNumbers: false, // Numbers are optional + requireSpecialChars: false, // Special chars are optional + preventCommonPasswords: true, // Still prevent common passwords + minStrengthScore: 0, // Accept any score for galleries + skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries + }; + + // Base validation with gallery-specific options + const result = validatePassword(password, galleryOptions); + + // Override validation for common date formats + // Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025" + const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/; + if (datePattern.test(password)) { + // Date format is valid for gallery passwords + return { + valid: true, + errors: [], + score: 2, + feedback: {} + }; + } + + // Additional gallery-specific checks + if (password.length < 6) { + result.valid = false; + result.errors = ['Password must be at least 6 characters long']; + } + + // Check if it's too simple (e.g., just "123456") + if (/^\d{1,6}$/.test(password)) { + result.valid = false; + result.errors.push('Password cannot be just numbers. Consider using a date format like "04.07.2025"'); + } + + return result; + } + + // Base validation for other contexts + const result = validatePassword(password); + + // Context-specific validation + if (context === 'admin') { + // Admins need stronger passwords + if (result.score < 4) { + result.valid = false; + result.errors.push('Admin passwords must be very strong (score 4/4)'); + } + + // Check password doesn't contain username + if (userData.username && password.toLowerCase().includes(userData.username.toLowerCase())) { + result.valid = false; + result.errors.push('Password must not contain your username'); + } + + // Check password doesn't contain email + if (userData.email) { + const emailUser = userData.email.split('@')[0]; + if (password.toLowerCase().includes(emailUser.toLowerCase())) { + result.valid = false; + result.errors.push('Password must not contain parts of your email'); + } + } + } + + return result; +} + +/** + * Generate a secure random password + * @param {Object} options - Generation options + * @returns {string} - Generated password + */ +function generateSecurePassword(options = {}) { + const config = { + length: options.length || 16, + includeUppercase: options.includeUppercase !== false, + includeLowercase: options.includeLowercase !== false, + includeNumbers: options.includeNumbers !== false, + includeSpecialChars: options.includeSpecialChars !== false, + excludeAmbiguous: options.excludeAmbiguous !== false + }; + + let charset = ''; + + if (config.includeLowercase) { + charset += config.excludeAmbiguous ? 'abcdefghjkmnpqrstuvwxyz' : 'abcdefghijklmnopqrstuvwxyz'; + } + + if (config.includeUppercase) { + charset += config.excludeAmbiguous ? 'ABCDEFGHJKLMNPQRSTUVWXYZ' : 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + } + + if (config.includeNumbers) { + charset += config.excludeAmbiguous ? '23456789' : '0123456789'; + } + + if (config.includeSpecialChars) { + charset += '!@#$%^&*()_+-=[]{}|;:,.<>?'; + } + + if (charset.length === 0) { + throw new Error('At least one character type must be included'); + } + + // Generate password + const crypto = require('crypto'); + let password = ''; + + for (let i = 0; i < config.length; i++) { + const randomIndex = crypto.randomInt(charset.length); + password += charset[randomIndex]; + } + + // Ensure password meets requirements + const validation = validatePassword(password); + if (!validation.valid) { + // Recursively generate until we get a valid password + return generateSecurePassword(options); + } + + return password; +} + +/** + * Get bcrypt rounds configuration + * @returns {number} - Number of bcrypt rounds to use + */ +function getBcryptRounds() { + return PASSWORD_CONFIG.bcryptRounds; +} + +/** + * Log password validation failures for security monitoring + * @param {string} context - Context of validation failure + * @param {Array} errors - Validation errors + * @param {Object} metadata - Additional metadata + */ +function logPasswordValidationFailure(context, errors, metadata = {}) { + logger.warn('Password validation failed', { + context, + errorCount: errors.length, + errors: errors.slice(0, 3), // Log first 3 errors only + ...metadata + }); +} + +module.exports = { + validatePassword, + validatePasswordInContext, + generateSecurePassword, + getBcryptRounds, + logPasswordValidationFailure, + PASSWORD_CONFIG +}; \ No newline at end of file diff --git a/backend/src/utils/rateLimitSecurity.js b/backend/src/utils/rateLimitSecurity.js new file mode 100644 index 0000000..d7614f6 --- /dev/null +++ b/backend/src/utils/rateLimitSecurity.js @@ -0,0 +1,119 @@ +/** + * Rate Limiting Security Utilities + * Provides secure rate limiting that prevents bypass attempts + */ + +const jwt = require('jsonwebtoken'); +const logger = require('./logger'); + +/** + * Safely check if a request has a valid admin token + * Used to determine if rate limiting should be skipped + * + * IMPORTANT: This prevents the bypass vulnerability where + * invalid tokens could skip rate limiting + * + * @param {Object} req - Express request object + * @returns {boolean} - True only if token is valid AND admin type + */ +function hasValidAdminToken(req) { + try { + // Only check admin paths + if (!req.path.startsWith('/api/admin/')) { + return false; + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return false; + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + // Critical: Verify token is valid before skipping rate limit + // This prevents invalid tokens from bypassing rate limiting + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + // Additional validation + if (!decoded || typeof decoded !== 'object') { + return false; + } + + // Must be admin type to skip rate limiting + if (decoded.type !== 'admin') { + logger.warn('Non-admin token attempted to bypass rate limit', { + path: req.path, + tokenType: decoded.type, + ip: req.ip + }); + return false; + } + + // Optional: Check token age (prevent old tokens) + const tokenAge = Date.now() - (decoded.iat * 1000); + const maxAge = 24 * 60 * 60 * 1000; // 24 hours + + if (tokenAge > maxAge) { + logger.warn('Old admin token attempted to bypass rate limit', { + path: req.path, + tokenAge: Math.floor(tokenAge / 1000 / 60) + ' minutes', + ip: req.ip + }); + return false; + } + + // Valid admin token - can skip rate limiting + return true; + + } catch (error) { + // Any error means token is invalid + // Log attempts with invalid tokens (potential attacks) + if (error.name === 'JsonWebTokenError') { + logger.warn('Invalid token attempted to bypass rate limit', { + path: req.path, + error: error.message, + ip: req.ip + }); + } + + // Apply rate limiting for any invalid token + return false; + } +} + +/** + * Create a skip function for rate limiter that prevents bypass + * @returns {Function} Skip function for express-rate-limit + */ +function createSecureSkipFunction() { + return (req) => { + // In development, be more lenient with public settings + if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') { + return true; + } + + // Only skip for valid admin tokens + return hasValidAdminToken(req); + }; +} + +/** + * Log rate limit hits for security monitoring + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +function logRateLimitHit(req, res) { + logger.warn('Rate limit exceeded', { + ip: req.ip, + path: req.path, + userAgent: req.headers['user-agent'], + remaining: res.getHeader('X-RateLimit-Remaining'), + limit: res.getHeader('X-RateLimit-Limit') + }); +} + +module.exports = { + hasValidAdminToken, + createSecureSkipFunction, + logRateLimitHit +}; \ No newline at end of file diff --git a/backend/src/utils/sqlSecurity.js b/backend/src/utils/sqlSecurity.js new file mode 100644 index 0000000..9eb0bce --- /dev/null +++ b/backend/src/utils/sqlSecurity.js @@ -0,0 +1,117 @@ +/** + * SQL Security Utilities + * Provides safe methods for handling user input in SQL queries + */ + +/** + * Validate and sanitize days parameter for date range queries + * @param {any} days - The days parameter from user input + * @returns {number} Safe integer between 1 and 365 + */ +function sanitizeDays(days) { + const parsed = parseInt(days); + + // Check if it's a valid number + if (isNaN(parsed)) { + return 7; // Default to 7 days + } + + // Ensure it's within reasonable bounds + if (parsed < 1) { + return 1; + } + + if (parsed > 365) { + return 365; // Maximum 1 year + } + + return parsed; +} + +/** + * Escape special characters in LIKE queries + * @param {string} input - The search string from user input + * @returns {string} Escaped string safe for LIKE queries + */ +function escapeLikePattern(input) { + if (!input || typeof input !== 'string') { + return ''; + } + + // Escape special LIKE pattern characters + // In SQL LIKE patterns: + // % matches any sequence of characters + // _ matches any single character + // \ is the escape character + return input + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/%/g, '\\%') // Escape percent signs + .replace(/_/g, '\\_') // Escape underscores + .replace(/'/g, '\'\''); // Escape single quotes for safety +} + +/** + * Create a safe date range condition using Knex + * @param {object} query - Knex query builder instance + * @param {string} column - The timestamp column name + * @param {number} days - Number of days to go back + * @returns {object} Modified query with safe date range condition + */ +function addDateRangeCondition(query, column, days) { + const safeDays = sanitizeDays(days); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - safeDays); + + // Use Knex's built-in date comparison which handles parameterization + return query.where(column, '>=', startDate.toISOString()); +} + +/** + * Create a safe LIKE condition using Knex + * @param {object} query - Knex query builder instance + * @param {string} column - The column to search + * @param {string} pattern - The search pattern + * @returns {object} Modified query with safe LIKE condition + */ +function addLikeCondition(query, column, pattern) { + if (!pattern || typeof pattern !== 'string') { + return query; + } + + const escapedPattern = escapeLikePattern(pattern); + // Knex handles parameterization of the LIKE value + return query.where(column, 'like', `%${escapedPattern}%`); +} + +/** + * Validate sort column against whitelist + * @param {string} column - The column name to sort by + * @param {string[]} allowedColumns - Array of allowed column names + * @param {string} defaultColumn - Default column if invalid + * @returns {string} Safe column name + */ +function validateSortColumn(column, allowedColumns, defaultColumn) { + if (!column || !allowedColumns.includes(column)) { + return defaultColumn; + } + return column; +} + +/** + * Validate sort order + * @param {string} order - The sort order (asc/desc) + * @returns {string} Safe sort order + */ +function validateSortOrder(order) { + const lowerOrder = (order || '').toLowerCase(); + return lowerOrder === 'asc' ? 'asc' : 'desc'; +} + +module.exports = { + sanitizeDays, + escapeLikePattern, + addDateRangeCondition, + addLikeCondition, + validateSortColumn, + validateSortOrder +}; \ No newline at end of file diff --git a/backend/src/utils/tokenRevocation.js b/backend/src/utils/tokenRevocation.js new file mode 100644 index 0000000..70fb9a7 --- /dev/null +++ b/backend/src/utils/tokenRevocation.js @@ -0,0 +1,133 @@ +/** + * Token Revocation System + * Provides ability to invalidate tokens before expiration + */ + +const { db } = require('../database/db'); +const logger = require('./logger'); + +/** + * Add a token to the revocation list + * @param {string} token - JWT token to revoke + * @param {string} reason - Reason for revocation + * @param {Object} metadata - Additional metadata + */ +async function revokeToken(token, reason, metadata = {}) { + try { + // Extract token info without full verification (it might be compromised) + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid token format'); + } + + // Decode payload + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + + await db('revoked_tokens').insert({ + token_id: payload.jti || `${payload.id}-${payload.iat}`, // JWT ID or fallback + user_id: payload.id, + token_type: payload.type, + revoked_at: new Date().toISOString(), + expires_at: new Date(payload.exp * 1000).toISOString(), + reason, + metadata: JSON.stringify(metadata) + }); + + logger.info('Token revoked', { + userId: payload.id, + tokenType: payload.type, + reason + }); + + return true; + } catch (error) { + logger.error('Failed to revoke token', error); + return false; + } +} + +/** + * Check if a token is revoked + * @param {Object} decodedToken - Decoded JWT payload + * @returns {boolean} - True if token is revoked + */ +async function isTokenRevoked(decodedToken) { + try { + const tokenId = decodedToken.jti || `${decodedToken.id}-${decodedToken.iat}`; + + const revoked = await db('revoked_tokens') + .where('token_id', tokenId) + .orWhere((builder) => { + builder + .where('user_id', decodedToken.id) + .where('revoked_at', '<=', new Date(decodedToken.iat * 1000).toISOString()); + }) + .first(); + + return !!revoked; + } catch (error) { + logger.error('Failed to check token revocation', error); + // Fail closed - treat as revoked if we can't check + return true; + } +} + +/** + * Revoke all tokens for a user + * @param {number} userId - User ID + * @param {string} reason - Reason for revocation + */ +async function revokeAllUserTokens(userId, reason) { + try { + // This effectively revokes all tokens by setting a revocation time + // Any token issued before this time will be considered revoked + await db('user_token_revocations').insert({ + user_id: userId, + revoked_at: new Date().toISOString(), + reason + }).onConflict('user_id').merge(); + + logger.info('All user tokens revoked', { userId, reason }); + return true; + } catch (error) { + logger.error('Failed to revoke user tokens', error); + return false; + } +} + +/** + * Clean up expired revoked tokens + * Should be run periodically + */ +async function cleanupExpiredRevocations() { + try { + const deleted = await db('revoked_tokens') + .where('expires_at', '<', new Date().toISOString()) + .delete(); + + if (deleted > 0) { + logger.info(`Cleaned up ${deleted} expired token revocations`); + } + } catch (error) { + logger.error('Failed to cleanup revoked tokens', error); + } +} + +/** + * Initialize cleanup job for expired revocations + */ +function initializeRevocationCleanup() { + // Run cleanup every 6 hours + setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000); + + // Run initial cleanup + cleanupExpiredRevocations(); +} + +module.exports = { + revokeToken, + isTokenRevoked, + revokeAllUserTokens, + cleanupExpiredRevocations, + initializeRevocationCleanup +}; \ No newline at end of file diff --git a/backend/wait-for-db.sh b/backend/wait-for-db.sh new file mode 100755 index 0000000..764ca01 --- /dev/null +++ b/backend/wait-for-db.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# wait-for-db.sh - Wait for PostgreSQL to be ready before starting the application + +set -e + +host="$DB_HOST" +port="${DB_PORT:-5432}" +user="${DB_USER:-picpeak}" + +echo "Waiting for PostgreSQL at $host:$port..." + +# Wait for PostgreSQL to be ready +until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do + >&2 echo "PostgreSQL is unavailable - sleeping" + sleep 2 +done + +>&2 echo "PostgreSQL is up - executing command" + +# Run migrations (use safe runner in production) +echo "Running database migrations..." +if [ "$NODE_ENV" = "production" ]; then + npm run migrate:safe +else + npm run migrate +fi + +# Execute the main command +exec "$@" \ No newline at end of file diff --git a/backend/wedding-photos.db b/backend/wedding-photos.db new file mode 100644 index 0000000..e69de29 diff --git a/clean-git-history-safe.sh b/clean-git-history-safe.sh new file mode 100755 index 0000000..2aa6f82 --- /dev/null +++ b/clean-git-history-safe.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Script to clean git history - removes all commits before July 17, 2025 +# Safe version: Moves current main to main-old and creates new clean main +# WARNING: This will rewrite history! + +set -e + +echo "โš ๏ธ WARNING: This script will rewrite git history!" +echo "โš ๏ธ All commits before July 17, 2025 will be removed." +echo "โš ๏ธ Current main branch will be preserved as main-old" +echo "" +read -p "Are you sure you want to continue? (type 'yes' to proceed): " confirmation + +if [ "$confirmation" != "yes" ]; then + echo "Operation cancelled." + exit 1 +fi + +# Check current state +CURRENT_BRANCH=$(git branch --show-current) +echo "Current branch: $CURRENT_BRANCH" + +# Check if we're in the middle of a cherry-pick or rebase +if [ -d ".git/CHERRY_PICK_HEAD" ] || [ -d ".git/rebase-merge" ] || [ -d ".git/rebase-apply" ]; then + echo "ERROR: You're in the middle of a cherry-pick or rebase. Please resolve or abort it first." + echo "To abort cherry-pick: git cherry-pick --abort" + echo "To abort rebase: git rebase --abort" + exit 1 +fi + +# Clean any previous attempts +echo "Cleaning up any previous attempts..." +git cherry-pick --abort 2>/dev/null || true +git rebase --abort 2>/dev/null || true + +# Check if main-old already exists +if git show-ref --verify --quiet refs/heads/main-old; then + echo "" + echo "โš ๏ธ Branch 'main-old' already exists!" + echo "Options:" + echo "1. Delete it and continue (previous backup will be lost)" + echo "2. Rename it with timestamp and continue" + echo "3. Cancel operation" + read -p "Choose option (1/2/3): " option + + case $option in + 1) + echo "Deleting existing main-old branch..." + git branch -D main-old + ;; + 2) + TIMESTAMP=$(date +%Y%m%d-%H%M%S) + NEW_NAME="main-old-${TIMESTAMP}" + echo "Renaming existing main-old to ${NEW_NAME}..." + git branch -m main-old "${NEW_NAME}" + ;; + 3) + echo "Operation cancelled." + exit 1 + ;; + *) + echo "Invalid option. Operation cancelled." + exit 1 + ;; + esac +fi + +# Make sure we're on main branch +if [ "$CURRENT_BRANCH" != "main" ]; then + echo "Switching to main branch..." + git checkout main +fi + +# Move current main to main-old +echo "Moving current main branch to main-old..." +git branch -m main main-old + +# Find the first commit on or after July 17, 2025 +echo "Finding first commit after July 17, 2025..." +FIRST_COMMIT=$(git log --since="2025-07-17" --reverse --format="%H" | head -1) + +if [ -z "$FIRST_COMMIT" ]; then + echo "ERROR: No commits found after July 17, 2025" + # Restore main branch + git branch -m main-old main + exit 1 +fi + +echo "First commit to keep: $FIRST_COMMIT" +echo "Commit details: $(git log --oneline -1 $FIRST_COMMIT)" + +# Count total commits to process +TOTAL_COMMITS=$(git log --since="2025-07-17" --oneline | wc -l) +echo "Total commits to preserve: $TOTAL_COMMITS" + +# Create new orphan branch for clean main +echo "Creating new clean main branch..." +git checkout --orphan main + +# Clean the working directory +git rm -rf . || true + +# Get the tree from the first commit +git checkout $FIRST_COMMIT -- . + +# Create new initial commit with same content +ORIGINAL_MESSAGE=$(git log --format="%B" -1 $FIRST_COMMIT) +ORIGINAL_AUTHOR=$(git log --format="%an <%ae>" -1 $FIRST_COMMIT) +ORIGINAL_DATE=$(git log --format="%ad" -1 $FIRST_COMMIT) + +GIT_AUTHOR_NAME=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f1 | xargs) +GIT_AUTHOR_EMAIL=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1) +GIT_AUTHOR_DATE="$ORIGINAL_DATE" +export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE + +git add -A +git commit -m "Initial commit - Project start (July 17, 2025) + +Original: $ORIGINAL_MESSAGE" + +# Now rebase the rest of the history onto the new main +echo "Rebasing remaining commits..." +echo "This will linearize the history (merge commits will be flattened)..." + +# Use rebase to apply all commits +git rebase --onto main $FIRST_COMMIT main-old || { + echo "" + echo "โš ๏ธ Rebase encountered conflicts!" + echo "" + echo "To resolve:" + echo "1. Fix the conflicts in the listed files" + echo "2. Stage the resolved files: git add " + echo "3. Continue rebase: git rebase --continue" + echo "4. If you want to abort and restore: git rebase --abort && git branch -D main && git branch -m main-old main" + echo "" + echo "After successful rebase, your main branch will have the clean history." + echo "The old history is preserved in main-old branch." + exit 1 +} + +# If we get here, rebase was successful +echo "" +echo "โœ… New clean history created successfully!" +echo "Total commits in new history: $(git rev-list --count HEAD)" +echo "" +echo "Branch status:" +echo " - main: Clean history starting from July 17, 2025" +echo " - main-old: Original history with all commits" +echo "" +echo "To push the new history to remote, run:" +echo " git push origin main --force" +echo "" +echo "To also push the old history backup:" +echo " git push origin main-old" +echo "" +echo "โš ๏ธ WARNING: Force pushing will overwrite the remote repository!" +echo "โš ๏ธ Make sure all team members are aware before pushing!" +echo "" +echo "If you need to restore the original history:" +echo " git checkout main-old" +echo " git branch -D main" +echo " git branch -m main" \ No newline at end of file diff --git a/clean-git-history-v2.sh b/clean-git-history-v2.sh new file mode 100755 index 0000000..d51bbb6 --- /dev/null +++ b/clean-git-history-v2.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +# Script to clean git history - removes all commits before July 17, 2025 +# Version 2: Handles merge commits properly +# WARNING: This is destructive and will rewrite history! + +set -e + +echo "โš ๏ธ WARNING: This script will permanently rewrite git history!" +echo "โš ๏ธ All commits before July 17, 2025 will be removed." +echo "โš ๏ธ This action cannot be undone!" +echo "" +read -p "Are you sure you want to continue? (type 'yes' to proceed): " confirmation + +if [ "$confirmation" != "yes" ]; then + echo "Operation cancelled." + exit 1 +fi + +# Check current state +CURRENT_BRANCH=$(git branch --show-current) +echo "Current branch: $CURRENT_BRANCH" + +# Check if we're in the middle of a cherry-pick +if [ -d ".git/CHERRY_PICK_HEAD" ]; then + echo "ERROR: You're in the middle of a cherry-pick. Please resolve or abort it first." + echo "To abort: git cherry-pick --abort" + echo "To continue: git cherry-pick --continue" + exit 1 +fi + +# Clean any previous attempts +echo "Cleaning up any previous attempts..." +git cherry-pick --abort 2>/dev/null || true +git checkout main 2>/dev/null || true +git branch -D new-main 2>/dev/null || true + +# Create backup branch +echo "Creating backup branch..." +BACKUP_BRANCH="backup-before-cleanup-$(date +%Y%m%d-%H%M%S)" +git checkout -b "$BACKUP_BRANCH" +git checkout main + +# Find the first commit on or after July 17, 2025 +echo "Finding first commit after July 17, 2025..." +FIRST_COMMIT=$(git log --since="2025-07-17" --reverse --format="%H" | head -1) + +if [ -z "$FIRST_COMMIT" ]; then + echo "ERROR: No commits found after July 17, 2025" + exit 1 +fi + +echo "First commit to keep: $FIRST_COMMIT" +echo "Commit details: $(git log --oneline -1 $FIRST_COMMIT)" + +# Count total commits to process +TOTAL_COMMITS=$(git log --since="2025-07-17" --oneline | wc -l) +echo "Total commits to preserve: $TOTAL_COMMITS" + +# Create new orphan branch +echo "Creating new clean history..." +git checkout --orphan new-main + +# Clean the working directory +git rm -rf . || true + +# Get the tree from the first commit +git checkout $FIRST_COMMIT -- . + +# Create new initial commit with same content +ORIGINAL_MESSAGE=$(git log --format="%B" -1 $FIRST_COMMIT) +ORIGINAL_AUTHOR=$(git log --format="%an <%ae>" -1 $FIRST_COMMIT) +ORIGINAL_DATE=$(git log --format="%ad" -1 $FIRST_COMMIT) + +GIT_AUTHOR_NAME=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f1 | xargs) +GIT_AUTHOR_EMAIL=$(echo "$ORIGINAL_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1) +GIT_AUTHOR_DATE="$ORIGINAL_DATE" +export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE + +git add -A +git commit -m "Initial commit - Project start (July 17, 2025) + +Original: $ORIGINAL_MESSAGE" + +# Now we'll use git rebase instead of cherry-pick to handle merges better +echo "Rebasing remaining commits..." +echo "This will linearize the history (merge commits will be flattened)..." + +# Get the commit range +LAST_COMMIT=$(git rev-parse main) + +# Use rebase to apply all commits +git rebase --onto new-main $FIRST_COMMIT main || { + echo "" + echo "โš ๏ธ Rebase encountered conflicts!" + echo "" + echo "To resolve:" + echo "1. Fix the conflicts in the listed files" + echo "2. Stage the resolved files: git add " + echo "3. Continue rebase: git rebase --continue" + echo "4. If you want to abort: git rebase --abort" + echo "" + echo "After successful rebase, run:" + echo " git branch -D main" + echo " git branch -m main" + echo " git push origin main --force" + exit 1 +} + +# If we get here, rebase was successful +echo "" +echo "โœ… New history created successfully!" +echo "Total commits in new history: $(git rev-list --count HEAD)" +echo "" +echo "Your backup branch is: $BACKUP_BRANCH" +echo "" +echo "To finalize the cleanup, run these commands:" +echo " git branch -D main" +echo " git branch -m main" +echo " git push origin main --force" +echo "" +echo "โš ๏ธ WARNING: Force pushing will overwrite the remote repository!" +echo "โš ๏ธ Make sure you have a backup and all team members are aware!" \ No newline at end of file diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d8cd1a8 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,120 @@ +# docker-compose.prod.yml - Production configuration +version: '3.8' + +services: + backend: + image: picpeak-backend:latest + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + - db + environment: + - NODE_ENV=production + - PORT=3000 + - JWT_SECRET=${JWT_SECRET} + - ADMIN_URL=${ADMIN_URL} + - FRONTEND_URL=${FRONTEND_URL} + # Database + - DATABASE_CLIENT=pg + - DB_HOST=db + - DB_PORT=5432 + - DB_USER=${DB_USER:-picpeak} + - DB_PASSWORD=${DB_PASSWORD} + - DB_NAME=${DB_NAME:-picpeak} + # Email + - SMTP_HOST=${SMTP_HOST} + - SMTP_PORT=${SMTP_PORT} + - SMTP_SECURE=${SMTP_SECURE} + - SMTP_USER=${SMTP_USER} + - SMTP_PASS=${SMTP_PASS} + - EMAIL_FROM=${EMAIL_FROM} + # Analytics + - UMAMI_URL=${UMAMI_URL} + - UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} + # Storage paths + - STORAGE_PATH=/app/storage + - EVENTS_PATH=/app/storage/events + - ARCHIVE_PATH=/app/storage/events/archived + volumes: + - ./storage:/app/storage + - ./data:/app/data + - ./logs:/app/logs + networks: + - picpeak + + frontend: + image: picpeak-frontend:latest + build: + context: ./frontend + dockerfile: Dockerfile + args: + - VITE_API_URL=/api + restart: unless-stopped + depends_on: + - backend + networks: + - picpeak + + nginx: + image: nginx:alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/sites-enabled:/etc/nginx/sites-enabled + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + depends_on: + - frontend + - backend + networks: + - picpeak + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + + certbot: + image: certbot/certbot + restart: unless-stopped + volumes: + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + + db: + image: postgres:14-alpine + restart: unless-stopped + environment: + - POSTGRES_USER=${DB_USER:-picpeak} + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=${DB_NAME:-picpeak} + # Allow connections from any host with password authentication + - POSTGRES_HOST_AUTH_METHOD=scram-sha-256 + - POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - picpeak + # Allow connections without SSL requirement from Docker network + command: postgres -c ssl=off + + umami: + image: ghcr.io/umami-software/umami:postgresql-latest + restart: unless-stopped + environment: + DATABASE_URL: postgresql://${DB_USER:-picpeak}:${DB_PASSWORD}@db:5432/umami + DATABASE_TYPE: postgresql + HASH_SALT: ${UMAMI_HASH_SALT} + depends_on: + - db + networks: + - picpeak + +networks: + picpeak: + driver: bridge + +volumes: + postgres_data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0ac1a65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,92 @@ +version: '3.8' + +services: + # PostgreSQL Database + postgres: + image: postgres:15-alpine + container_name: picpeak-postgres + environment: + POSTGRES_DB: ${DB_NAME:-picpeak} + POSTGRES_USER: ${DB_USER:-picpeak} + POSTGRES_PASSWORD: ${DB_PASSWORD:-picpeak} + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"] + interval: 10s + timeout: 5s + retries: 5 + + # Backend API + backend: + build: ./backend + container_name: picpeak-backend + depends_on: + postgres: + condition: service_healthy + environment: + NODE_ENV: production + DATABASE_CLIENT: pg + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-picpeak} + DB_USER: ${DB_USER:-picpeak} + DB_PASSWORD: ${DB_PASSWORD:-picpeak} + env_file: + - .env + volumes: + - ./storage:/app/storage + - ./data:/app/data + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # Frontend + frontend: + build: + context: ./frontend + args: + VITE_API_URL: ${VITE_API_URL:-/api} + VITE_UMAMI_URL: ${VITE_UMAMI_URL} + VITE_UMAMI_WEBSITE_ID: ${VITE_UMAMI_WEBSITE_ID} + container_name: picpeak-frontend + depends_on: + - backend + restart: unless-stopped + + # Nginx Reverse Proxy + nginx: + image: nginx:alpine + container_name: picpeak-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + depends_on: + - frontend + - backend + restart: unless-stopped + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + + # Certbot for SSL + certbot: + image: certbot/certbot + container_name: picpeak-certbot + volumes: + - ./certbot/conf:/etc/letsencrypt + - ./certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + +volumes: + postgres_data: + +networks: + default: + name: picpeak-network \ No newline at end of file diff --git a/docker/postgres-init/01-create-umami-db.sql b/docker/postgres-init/01-create-umami-db.sql new file mode 100644 index 0000000..d62d32b --- /dev/null +++ b/docker/postgres-init/01-create-umami-db.sql @@ -0,0 +1,8 @@ +-- Create umami database if it doesn't exist +-- This runs as the postgres superuser during initialization + +SELECT 'CREATE DATABASE umami' +WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'umami')\gexec + +-- Grant all privileges on umami database to the application user +GRANT ALL PRIVILEGES ON DATABASE umami TO "${POSTGRES_USER}"; \ No newline at end of file diff --git a/docs/ADMIN_SETUP_GUIDE.md b/docs/ADMIN_SETUP_GUIDE.md new file mode 100644 index 0000000..718765f --- /dev/null +++ b/docs/ADMIN_SETUP_GUIDE.md @@ -0,0 +1,164 @@ +# Admin Setup Guide - Secure Password System + +## Overview + +PicPeak now uses a secure admin setup process that eliminates the default password vulnerability. When you first set up the application, a secure password is automatically generated for the admin account. + +## Initial Setup Process + +### 1. First Installation + +When you run the database migrations for the first time: + +```bash +cd backend +npm run migrate +``` + +The system will: +- Create an admin user with username `admin` +- Generate a secure, random password (e.g., `SwiftEagle3847!`) +- Display the credentials in the console +- Save the credentials to `ADMIN_CREDENTIALS.txt` + +### 2. Retrieving Your Credentials + +After setup, you can find your admin credentials in: +- **Console output** - Displayed immediately after setup +- **ADMIN_CREDENTIALS.txt** - File in the project root + +**Example output:** +``` +======================================== +โœ… Admin user created successfully! +======================================== +Username: admin +Password: SwiftEagle3847! + +โš ๏ธ IMPORTANT: +1. Save these credentials securely +2. You will be required to change the password on first login +3. Credentials are also saved in: ADMIN_CREDENTIALS.txt +======================================== +``` + +### 3. First Login + +1. Navigate to the admin panel: `http://localhost:3001/admin` +2. Login with: + - Username: `admin` + - Password: (from ADMIN_CREDENTIALS.txt) +3. You will be prompted to change your password immediately + +### 4. Password Requirements + +When changing your password, it must meet these requirements: +- Minimum 12 characters long +- Contains uppercase letters (A-Z) +- Contains lowercase letters (a-z) +- Contains numbers (0-9) +- Contains special characters (!@#$%^&*()_+-=[]{}|;:,.<>?) +- Not a common password + +## Security Features + +### Generated Passwords +- Uses cryptographically secure random generation +- Human-readable format: `AdjectiveNoun####!` +- Example: `BrightMountain7823$` + +### Password Storage +- Passwords are hashed using bcrypt with 12 rounds +- Original password is never stored in the database +- Credentials file should be deleted after noting the password + +### Forced Password Change +- Admin must change password on first login +- System tracks `must_change_password` flag +- Cannot access admin features until password is changed + +## Troubleshooting + +### Lost Admin Password + +If you lose the admin password before first login: + +1. Delete the admin user from the database: + ```sql + DELETE FROM admin_users WHERE username = 'admin'; + ``` + +2. Run migrations again: + ```bash + npm run migrate + ``` + +3. New credentials will be generated + +### Password Change Issues + +If you can't change your password: +- Ensure new password meets all requirements +- Check for detailed error messages +- Password strength validator provides specific feedback + +### Can't Find Credentials File + +If ADMIN_CREDENTIALS.txt is missing: +- Check the console output from when you ran migrations +- File is created in the backend directory root +- File might have been deleted for security (as recommended) + +## Best Practices + +1. **Immediate Action** + - Change the generated password on first login + - Use a password manager to store credentials + - Delete ADMIN_CREDENTIALS.txt after noting the password + +2. **Password Security** + - Use unique passwords for each environment + - Rotate passwords regularly (every 90 days) + - Never share admin credentials + +3. **Multiple Admins** + - Create separate admin accounts for each person + - Avoid sharing the main admin account + - Use role-based access control when available + +## Migration from Old System + +If upgrading from the old system with hardcoded `admin123`: + +1. The system will detect existing admin user +2. You must manually reset the password: + ```bash + # Run the password reset script + node scripts/reset-admin-password.js + ``` + +3. Follow the new secure password process + +## Environment-Specific Setup + +### Development +- Generated passwords are suitable for development +- Consider using simpler passwords for convenience +- Always use strong passwords in staging/production + +### Production +- Generate new admin account for production +- Use extremely strong passwords (20+ characters) +- Enable two-factor authentication when available +- Regularly audit admin access logs + +## Security Checklist + +- [ ] Retrieved generated password from ADMIN_CREDENTIALS.txt +- [ ] Logged in successfully with generated password +- [ ] Changed password to a strong, unique password +- [ ] Deleted ADMIN_CREDENTIALS.txt file +- [ ] Stored new password in password manager +- [ ] Tested login with new password +- [ ] Set up additional admin accounts if needed +- [ ] Configured password policies for organization \ No newline at end of file diff --git a/docs/JWT_SECRET_MIGRATION.md b/docs/JWT_SECRET_MIGRATION.md new file mode 100644 index 0000000..c73e46b --- /dev/null +++ b/docs/JWT_SECRET_MIGRATION.md @@ -0,0 +1,109 @@ +# JWT_SECRET Security Fix - Migration Guide + +## Overview + +A critical security vulnerability has been fixed where the application would fall back to a hardcoded JWT secret (`'your-secret-key'`) if the `JWT_SECRET` environment variable was not set. This has been addressed by: + +1. Adding startup validation that requires `JWT_SECRET` to be set +2. Removing all hardcoded fallback values +3. Ensuring the secret meets minimum security requirements + +## Changes Made + +### 1. Added Environment Validation (`backend/src/config/validateEnv.js`) +- The server now validates critical environment variables at startup +- If `JWT_SECRET` is missing or set to the insecure default, the server will refuse to start +- Warns if `JWT_SECRET` is less than 32 characters (recommended minimum) + +### 2. Updated Server Startup (`backend/server.js`) +- Added validation call immediately after loading environment variables +- Ensures all routes and middleware have access to validated configuration + +### 3. Removed Hardcoded Fallbacks (`backend/src/routes/protectedImages.js`) +- Removed `|| 'your-secret-key'` fallback from lines 15 and 27 +- Functions now rely on the validated `JWT_SECRET` from environment + +## Migration Steps for Production + +### Before Deployment + +1. **Verify JWT_SECRET is set in production**: + ```bash + # Check if JWT_SECRET is set + echo $JWT_SECRET + ``` + +2. **Ensure JWT_SECRET is secure**: + - Must NOT be `'your-secret-key'` + - Should be at least 32 characters long + - Should be randomly generated + +3. **Generate a secure JWT_SECRET if needed**: + ```bash + # Generate a secure 64-character secret + openssl rand -hex 32 + ``` + +### Deployment Process + +1. **Update environment variables** (if needed): + ```bash + # Example for .env file + JWT_SECRET=your-secure-64-character-random-string-here + ``` + +2. **Deploy the updated code** + +3. **Monitor startup logs** to ensure no validation errors: + ``` + โœ“ Environment validation passed + โœ“ Server running on port 3000 + ``` + +### Rollback Plan + +If the deployment fails due to missing `JWT_SECRET`: + +1. **Quick Fix** (temporary): + - Set `JWT_SECRET` environment variable to a secure value + - Restart the application + +2. **Full Rollback** (if needed): + - Revert to previous version + - Set `JWT_SECRET` properly before attempting deployment again + +## Verification + +After deployment, verify the fix is working: + +1. **Check server logs** for successful startup +2. **Test authentication** to ensure JWT tokens are working +3. **Verify image protection** routes are functioning + +## Security Considerations + +- **Never** commit JWT_SECRET to version control +- **Rotate** JWT_SECRET periodically +- **Use different** secrets for different environments (dev, staging, production) +- **Monitor** for authentication failures that might indicate token issues + +## Troubleshooting + +### Server won't start +- **Error**: "Missing required environment variable: JWT_SECRET" +- **Solution**: Set the JWT_SECRET environment variable + +### JWT_SECRET rejection +- **Error**: "JWT_SECRET is set to the insecure default value" +- **Solution**: Change JWT_SECRET from 'your-secret-key' to a secure value + +### Authentication failures after deployment +- **Cause**: Existing tokens were signed with old secret +- **Solution**: Users will need to re-authenticate to get new tokens + +## Support + +If you encounter issues during migration: +1. Check the server logs for specific error messages +2. Verify environment variables are properly set +3. Ensure the JWT_SECRET value doesn't contain special characters that might need escaping \ No newline at end of file diff --git a/docs/SECURITY_BEST_PRACTICES.md b/docs/SECURITY_BEST_PRACTICES.md new file mode 100644 index 0000000..2f67d5f --- /dev/null +++ b/docs/SECURITY_BEST_PRACTICES.md @@ -0,0 +1,179 @@ +# Security Best Practices for PicPeak + +## JWT Secret Management + +### Generating Secure Secrets + +Always generate cryptographically secure random secrets for JWT signing: + +```bash +# Generate a 64-character hex string (256 bits) +openssl rand -hex 32 + +# Alternative: Generate a base64 string +openssl rand -base64 32 + +# Alternative: Using Node.js +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +``` + +### Environment-Specific Secrets + +**NEVER use the same JWT secret across different environments!** + +- **Development**: Use the secure secret in `docker-compose.yml` +- **Staging**: Generate a unique secret for staging +- **Production**: Generate a unique secret for production + +### Secret Requirements + +1. **Minimum Length**: 32 characters (enforced by application) +2. **Recommended Length**: 64 characters (256 bits) +3. **Character Set**: Use hex or base64 encoding +4. **Uniqueness**: Each environment must have a unique secret + +### What NOT to Do + +โŒ **Never commit real secrets to version control** +```bash +# Bad - real secret in code +JWT_SECRET=my-actual-production-secret +``` + +โŒ **Never use predictable or weak secrets** +```bash +# Bad examples +JWT_SECRET=secret123 +JWT_SECRET=mycompanyname +JWT_SECRET=password +JWT_SECRET=your-secret-key +``` + +โŒ **Never share secrets between environments** +```bash +# Bad - same secret everywhere +DEV_JWT_SECRET=same-secret +PROD_JWT_SECRET=same-secret +``` + +### Secure Secret Storage + +#### For Local Development +- Docker Compose files can contain development secrets +- These should still be secure random values + +#### For Production +1. **Environment Variables** + ```bash + # Set via secure environment + export JWT_SECRET=$(openssl rand -hex 32) + ``` + +2. **Secret Management Services** + - AWS Secrets Manager + - HashiCorp Vault + - Azure Key Vault + - Kubernetes Secrets + +3. **CI/CD Integration** + - Store secrets in CI/CD platform's secret storage + - Never log or echo secrets in build scripts + +### Secret Rotation + +Implement a secret rotation strategy: + +1. **Regular Rotation**: Rotate secrets every 90 days +2. **Incident Response**: Rotate immediately if compromised +3. **Graceful Rotation**: Support multiple valid secrets during transition + +### Monitoring and Alerts + +1. **Startup Validation**: Application refuses to start without proper JWT_SECRET +2. **Length Warnings**: Warnings for secrets shorter than 32 characters +3. **Default Detection**: Critical error if default secret is detected + +## Additional Security Measures + +### Password Requirements +- Minimum 12 characters +- Mix of uppercase, lowercase, numbers, and special characters +- Check against common password lists +- Implement password strength meter + +### Session Security +- Implement token expiration (24 hours for admin, configurable for galleries) +- Add refresh token mechanism +- Implement token revocation +- Use secure session storage (Redis in production) + +### API Security +- Rate limiting on all endpoints +- Extra strict limits on authentication endpoints +- CSRF protection for state-changing operations +- Input validation on all user inputs + +### File Upload Security +- Validate file types by content, not just extension +- Implement virus scanning +- Limit file sizes +- Sanitize filenames +- Store files outside web root + +### Database Security +- Use parameterized queries (Knex.js handles this) +- Validate and sanitize all inputs +- Implement query timeouts +- Use least-privilege database users + +### HTTPS and Headers +- Always use HTTPS in production +- Implement security headers: + - Strict-Transport-Security + - X-Frame-Options + - X-Content-Type-Options + - Content-Security-Policy + - X-XSS-Protection + +### Logging and Monitoring +- Log authentication attempts +- Monitor for suspicious patterns +- Never log sensitive data (passwords, tokens) +- Implement audit trails for admin actions + +## Security Checklist for Deployment + +- [ ] Generate unique JWT_SECRET for environment +- [ ] Verify JWT_SECRET meets minimum requirements +- [ ] Store secrets securely (not in code) +- [ ] Enable HTTPS +- [ ] Configure security headers +- [ ] Set up rate limiting +- [ ] Enable audit logging +- [ ] Test authentication flows +- [ ] Verify file upload restrictions +- [ ] Check database query security + +## Incident Response + +If a security incident occurs: + +1. **Immediate Actions** + - Rotate all secrets + - Review access logs + - Disable compromised accounts + +2. **Investigation** + - Analyze logs for unauthorized access + - Check for data exfiltration + - Review code changes + +3. **Recovery** + - Deploy security patches + - Force password resets if needed + - Notify affected users + +4. **Prevention** + - Update security practices + - Implement additional monitoring + - Conduct security audit \ No newline at end of file diff --git a/docs/nginx-fix.md b/docs/nginx-fix.md new file mode 100644 index 0000000..a3407cf --- /dev/null +++ b/docs/nginx-fix.md @@ -0,0 +1,59 @@ +# Nginx Configuration Fix for Photo Authentication + +If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy. + +## Common Issue + +The `Authorization` header is often not passed through by default in nginx proxy configurations. + +## Fix + +Add these lines to your nginx configuration for the PicPeak location block: + +```nginx +location / { + proxy_pass http://localhost:3001; + + # Important: Pass the Authorization header + proxy_pass_header Authorization; + proxy_set_header Authorization $http_authorization; + + # Other standard proxy headers + 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; +} +``` + +## Alternative Fix Using Traefik + +If using Traefik, ensure headers are passed: + +```yaml +services: + picpeak: + labels: + - "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization=" +``` + +## Testing + +1. Check if Authorization header is reaching the backend: + ```bash + curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v + ``` + +2. Check nginx logs to see if the header is present: + ```bash + tail -f /var/log/nginx/access.log + ``` + +## Docker Compose Fix + +If using docker-compose with nginx proxy, add: + +```yaml +environment: + - NGINX_PROXY_PASS_HEADER=Authorization +``` \ No newline at end of file diff --git a/docs/picpeak-logo.png b/docs/picpeak-logo.png new file mode 100644 index 0000000..8ce30c9 Binary files /dev/null and b/docs/picpeak-logo.png differ diff --git a/docs/screenshot-analytics.png b/docs/screenshot-analytics.png new file mode 100644 index 0000000..1668459 Binary files /dev/null and b/docs/screenshot-analytics.png differ diff --git a/docs/screenshot-dashboard.png b/docs/screenshot-dashboard.png new file mode 100644 index 0000000..7d91494 Binary files /dev/null and b/docs/screenshot-dashboard.png differ diff --git a/docs/screenshot-gallery.png b/docs/screenshot-gallery.png new file mode 100644 index 0000000..2af2e56 Binary files /dev/null and b/docs/screenshot-gallery.png differ diff --git a/docs/screenshots-events.png b/docs/screenshots-events.png new file mode 100644 index 0000000..f907561 Binary files /dev/null and b/docs/screenshots-events.png differ diff --git a/frontend/.claudedocs/scans/security-2025-01-12.md b/frontend/.claudedocs/scans/security-2025-01-12.md new file mode 100644 index 0000000..77b87fe --- /dev/null +++ b/frontend/.claudedocs/scans/security-2025-01-12.md @@ -0,0 +1,263 @@ +# PicPeak Security Scan Report + +**Date**: January 12, 2025 +**Scan Type**: Comprehensive Security Audit +**Platform**: PicPeak Photo Sharing Platform +**Scanner**: Claude Code Security Scanner + +## Executive Summary + +A comprehensive security scan of the PicPeak photo sharing platform reveals **critical vulnerabilities** that require immediate attention. While the application implements some security best practices, several high-severity issues could lead to data breaches, unauthorized access, and system compromise. + +### Overall Risk Assessment: **HIGH** ๐Ÿ”ด + +**Critical Issues Found**: 8 +**High-Risk Issues**: 7 +**Medium-Risk Issues**: 6 +**Low-Risk Issues**: 2 + +## Critical Vulnerabilities Requiring Immediate Action + +### 1. Hardcoded Secrets and Credentials ๐Ÿ”ด + +#### JWT Secret Fallback +- **Location**: `backend/src/routes/protectedImages.js:15,27` +- **Severity**: CRITICAL +- **Impact**: Complete authentication bypass if environment variable not set +```javascript +const secret = process.env.JWT_SECRET || 'your-secret-key'; // VULNERABLE +``` + +#### Default Admin Password +- **Location**: `backend/migrations/init.js:14`, `setup-remaining-files.sh:121` +- **Severity**: HIGH +- **Impact**: Known default credentials allow unauthorized admin access +- **Current**: Hardcoded `admin123` password + +### 2. SQL Injection Vulnerabilities ๐Ÿ”ด + +#### Direct Template Literal Interpolation +- **Location**: `backend/src/routes/adminDashboard.js:214,221,227,252,269` +- **Severity**: HIGH +- **Impact**: Potential database compromise +```javascript +.whereRaw(`timestamp >= datetime("now", "-${days} days")`) // VULNERABLE +``` + +#### LIKE Query Injection +- **Locations**: + - `backend/src/routes/adminPhotos.js:476` + - `backend/src/routes/adminEvents.js:156-158` +- **Severity**: MEDIUM +- **Impact**: Query manipulation through special characters + +### 3. Authentication & Authorization Flaws ๐Ÿ”ด + +#### Missing Token Type Validation +- **Location**: Admin middleware +- **Severity**: HIGH +- **Impact**: Gallery tokens could potentially access admin endpoints + +#### Weak Password Requirements +- **Current**: Only 6 characters minimum +- **Severity**: MEDIUM +- **Impact**: Vulnerable to brute force attacks + +#### Rate Limiting Bypass +- **Location**: `backend/server.js:57-73` +- **Severity**: HIGH +- **Impact**: Invalid JWT tokens bypass rate limiting + +### 4. Cross-Site Scripting (XSS) ๐Ÿ”ด + +#### Stored XSS in CMS +- **Location**: `frontend/src/pages/public/LegalPage.tsx:106` +- **Severity**: CRITICAL +- **Impact**: Malicious scripts execute for all visitors +```tsx +dangerouslySetInnerHTML={{ __html: page.content }} // VULNERABLE +``` + +### 5. File Upload Vulnerabilities ๐ŸŸก + +#### Path Traversal Risk +- **Location**: `backend/server.js:104-110` +- **Severity**: HIGH +- **Impact**: Access to files outside intended directories + +#### Insufficient MIME Type Validation +- **Multiple locations** +- **Severity**: MEDIUM +- **Impact**: Malicious file upload bypass + +### 6. Security Headers & Configuration ๐ŸŸก + +#### Missing Critical Headers +- **Missing**: CSP, X-Frame-Options, Strict-Transport-Security +- **Severity**: MEDIUM +- **Impact**: Reduced defense against various attacks + +#### Permissive CORS Configuration +- **Location**: `backend/server.js:30-49` +- **Severity**: MEDIUM +- **Impact**: Allows multiple origins including localhost + +## Dependency Analysis + +### NPM Audit Results โœ… +- **Backend**: 0 vulnerabilities found +- **Frontend**: 0 vulnerabilities found +- **Status**: All dependencies are up to date + +## Detailed Findings by Category + +### Authentication Security + +1. **JWT Implementation Issues**: + - No refresh token mechanism + - 24-hour token expiration for all types + - No token revocation capability + - Hardcoded fallback secret + +2. **Session Management**: + - In-memory session storage (not scalable) + - No Redis implementation despite comments + - Incomplete session cleanup + +3. **Password Security**: + - Weak requirements (6 chars minimum) + - Fixed bcrypt rounds (10) + - No password complexity requirements + - No breach checking + +### Data Security + +1. **SQL Injection Risks**: + - Template literal interpolation in whereRaw() + - Unescaped LIKE queries + - Missing input validation on some parameters + +2. **XSS Vulnerabilities**: + - Stored XSS in CMS content + - No Content Security Policy + - Missing output encoding in some areas + +3. **Information Disclosure**: + - Detailed error messages exposed + - Console.error statements with sensitive data + - No audit logging for security events + +### Infrastructure Security + +1. **File Upload Issues**: + - Path traversal vulnerability + - Weak MIME type validation + - No virus scanning + - Missing content validation + +2. **Network Security**: + - Missing security headers + - Permissive CORS policy + - No HTTPS enforcement + - Rate limiting can be bypassed + +## Recommended Fixes + +### Priority 1: Critical (Implement Immediately) + +1. **Remove Hardcoded Secrets** +```javascript +// Replace fallback with error +const secret = process.env.JWT_SECRET; +if (!secret) { + throw new Error('JWT_SECRET environment variable is required'); +} +``` + +2. **Fix SQL Injection** +```javascript +// Use parameterized queries +.whereRaw('timestamp >= datetime("now", ? || " days")', [`-${days}`]) +``` + +3. **Sanitize CMS Content** +```javascript +import DOMPurify from 'dompurify'; +dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(page.content) }} +``` + +### Priority 2: High (Implement Within 1 Week) + +1. **Add Token Type Validation** +```javascript +if (decoded.type !== 'admin') { + return res.status(401).json({ error: 'Invalid token type' }); +} +``` + +2. **Implement Security Headers** +```javascript +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "https:"], + }, + }, +})); +``` + +3. **Fix Rate Limiting Bypass** +```javascript +// Check token validity before skipping rate limit +try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + return decoded && decoded.type === 'admin'; +} catch (err) { + return false; // Apply rate limiting on invalid tokens +} +``` + +### Priority 3: Medium (Implement Within 1 Month) + +1. **Enhance Password Security** + - Minimum 12 characters + - Complexity requirements + - Breach checking integration + +2. **Implement File Security** + - Content-based validation + - Path traversal protection + - Virus scanning + +3. **Add Security Monitoring** + - Audit logging + - Failed login tracking + - Anomaly detection + +## Security Checklist + +- [ ] Remove all hardcoded secrets +- [ ] Fix SQL injection vulnerabilities +- [ ] Add XSS protection (DOMPurify) +- [ ] Implement proper token validation +- [ ] Add all security headers +- [ ] Fix rate limiting bypass +- [ ] Enhance password requirements +- [ ] Add file upload security +- [ ] Implement audit logging +- [ ] Set up security monitoring +- [ ] Document security procedures +- [ ] Conduct penetration testing + +## Conclusion + +The PicPeak platform has significant security vulnerabilities that need immediate attention. The most critical issues are hardcoded secrets, SQL injection risks, and stored XSS vulnerabilities. While the codebase shows some security awareness (bcrypt hashing, JWT usage, input validation), the implementation has serious flaws that could lead to system compromise. + +**Recommended Action**: Address all critical vulnerabilities immediately before deploying to production. Consider a professional security audit after implementing these fixes. + +--- +*Generated by Claude Code Security Scanner* +*Scan completed: 2025-01-12* \ No newline at end of file diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3413618 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,18 @@ +node_modules +dist +.git +.gitignore +.env* +.DS_Store +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.vscode +.idea +*.swp +*.swo +README.md +.eslintcache +coverage +.nyc_output \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..b8f3c08 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +# Backend API URL +VITE_API_URL=http://localhost:3000 + +# Umami Analytics Configuration +# Get these values from your Umami installation +VITE_UMAMI_URL=https://analytics.yourdomain.com +VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami + +# Optional: Umami share URL for embedding full dashboard +# This is the public share URL from Umami's share feature +VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing \ No newline at end of file diff --git a/frontend/.env.production.example b/frontend/.env.production.example new file mode 100644 index 0000000..11ca0a6 --- /dev/null +++ b/frontend/.env.production.example @@ -0,0 +1,14 @@ +# Production Environment Configuration +# When running behind a reverse proxy like Traefik, use relative URLs + +# Backend API URL +# For production behind reverse proxy, use relative URL: +VITE_API_URL=/api + +# For development or if frontend/backend are on different domains: +# VITE_API_URL=https://api.yourdomain.com + +# Umami Analytics Configuration (optional) +# VITE_UMAMI_URL=https://analytics.yourdomain.com +# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami +# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..e1d31fb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment files +.env +.env.local +.env.*.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ca73e13 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,52 @@ +# Build stage +FROM node:20-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --legacy-peer-deps + +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Install runtime dependencies +RUN apk add --no-cache curl + +# Remove default nginx config +RUN rm -rf /etc/nginx/conf.d/* + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built application from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Set permissions (nginx user already exists in nginx:alpine) +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + touch /var/run/nginx.pid && \ + chown -R nginx:nginx /var/run/nginx.pid + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/health || exit 1 + +# Switch to non-root user +USER nginx + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..b58da54 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,45 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Accept build arguments +ARG VITE_API_URL +ARG VITE_UMAMI_URL +ARG VITE_UMAMI_WEBSITE_ID + +# Set environment variables for build +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_UMAMI_URL=$VITE_UMAMI_URL +ENV VITE_UMAMI_WEBSITE_ID=$VITE_UMAMI_WEBSITE_ID + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --legacy-peer-deps + +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy custom nginx config +COPY nginx.dev.conf /etc/nginx/conf.d/default.conf + +# Copy built application from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/health || exit 1 + +# Expose port +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..2e0c6bb --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,65 @@ +# Build stage with dynamic API URL +FROM node:20-alpine AS builder + +# Accept build args for API URL +ARG VITE_API_URL=/api + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --legacy-peer-deps + +# Copy source files +COPY . . + +# Set environment variable for build +ENV VITE_API_URL=$VITE_API_URL + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Install runtime dependencies +RUN apk add --no-cache curl + +# Remove default nginx config +RUN rm -rf /etc/nginx/conf.d/* + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built application from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Create a script to inject runtime config +RUN cat > /usr/share/nginx/html/config.js << 'EOF' +window.__RUNTIME_CONFIG__ = { + API_URL: '/api' +}; +EOF + +# Set permissions +RUN chown -R nginx:nginx /usr/share/nginx/html && \ + chown -R nginx:nginx /var/cache/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + touch /var/run/nginx.pid && \ + chown -R nginx:nginx /var/run/nginx.pid + +# Expose port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/health || exit 1 + +# Switch to non-root user +USER nginx + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7959ce4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/audit-frontend.json b/frontend/audit-frontend.json new file mode 100644 index 0000000..c25ee55 --- /dev/null +++ b/frontend/audit-frontend.json @@ -0,0 +1,22 @@ +{ + "auditReportVersion": 2, + "vulnerabilities": {}, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 0, + "critical": 0, + "total": 0 + }, + "dependencies": { + "prod": 136, + "dev": 298, + "optional": 47, + "peer": 0, + "peerOptional": 0, + "total": 433 + } + } +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..d94e7de --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e040cab --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + PicPeak - Photo Sharing Platform + + +
    + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..fd24354 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,105 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always; + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Cache index.html with revalidation + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + # API proxy + location /api { + proxy_pass http://backend: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; + proxy_read_timeout 86400; + } + + # Photo serving proxy + location /photos { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + 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; + + # Cache photos + proxy_cache_valid 200 302 1d; + proxy_cache_valid 404 1m; + } + + # Thumbnail serving proxy + location /thumbnails { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + 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; + + # Cache thumbnails + proxy_cache_valid 200 302 7d; + proxy_cache_valid 404 1m; + } + + # Uploads serving proxy (logos, favicons, watermarks) + location /uploads { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + 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; + + # Cache uploads + proxy_cache_valid 200 302 7d; + proxy_cache_valid 404 1m; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + } +} \ No newline at end of file diff --git a/frontend/nginx.dev.conf b/frontend/nginx.dev.conf new file mode 100644 index 0000000..498492b --- /dev/null +++ b/frontend/nginx.dev.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + # API proxy to backend + location /api { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + 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; + } + + # Photos proxy to backend + location /photos { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + # Health check + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..35d0cd7 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6030 @@ +{ + "name": "picpeak-frontend", + "version": "1.0.61", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "picpeak-frontend", + "version": "1.0.61", + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "@tiptap/extension-character-count": "^2.26.1", + "@tiptap/extension-code-block-lowlight": "^2.26.1", + "@tiptap/extension-hard-break": "^2.26.1", + "@tiptap/extension-link": "^2.25.0", + "@tiptap/extension-placeholder": "^2.26.1", + "@tiptap/extension-text-align": "^2.26.1", + "@tiptap/react": "^2.25.0", + "@tiptap/starter-kit": "^2.25.0", + "@types/dompurify": "^3.0.5", + "@types/lodash": "^4.17.20", + "@types/react-google-recaptcha": "^2.1.9", + "axios": "^1.3.2", + "clsx": "^2.0.0", + "date-fns": "^2.29.3", + "dompurify": "^3.2.6", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "lowlight": "^2.9.0", + "lucide-react": "^0.292.0", + "react": "^18.3.1", + "react-countdown": "^2.3.5", + "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", + "react-i18next": "^15.6.0", + "react-image-gallery": "^1.2.11", + "react-intersection-observer": "^9.4.3", + "react-router-dom": "^6.8.0", + "react-toastify": "^9.1.1", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/js-cookie": "^3.0.6", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.5.2", + "autoprefixer": "^10.4.13", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.4.21", + "tailwindcss": "^3.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", + "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.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.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "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.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.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.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "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.0", + "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/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/js": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "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.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", + "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "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/@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.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.19", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", + "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", + "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", + "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", + "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", + "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", + "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", + "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", + "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", + "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", + "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", + "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", + "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", + "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", + "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", + "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", + "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", + "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", + "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", + "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", + "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", + "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz", + "integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.81.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", + "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.81.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tiptap/core": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.25.0.tgz", + "integrity": "sha512-pTLV0+g+SBL49/Y5A9ii7oHwlzIzpgroJVI3AcBk7/SeR7554ZzjxxtJmZkQ9/NxJO+k1jQp9grXaqqOLqC7cA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.25.0.tgz", + "integrity": "sha512-W+sVPlV9XmaNPUkxV2BinNEbk2hr4zw8VgKjqKQS9O0k2YIVRCfQch+4DudSAwBVMrVW97zVAKRNfictGFQ8vQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.25.0.tgz", + "integrity": "sha512-3cBX2EtdFR3+EDTkIshhpQpXoZQbFUzxf6u86Qm0qD49JnVOjX9iexnUp8MydXPZA6NVsKeEfMhf18gV7oxTEw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.25.0.tgz", + "integrity": "sha512-BnbfQWRXJDDy9/x/0Atu2Nka5ZAMyXLDFqzSLMAXqXSQcG6CZRTSNRgOCnjpda6Hq2yCtq7l/YEoXkbHT1ZZdQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.25.0.tgz", + "integrity": "sha512-KD+q/q6KIU2anedjtjG8vELkL5rYFdNHWc5XcUJgQoxbOCK3/sBuOgcn9mnFA2eAS6UkraN9Yx0BXEDbXX2HOw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-character-count": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.26.1.tgz", + "integrity": "sha512-F7LP1a9GF28thbApowWT2I41baqX74HMUTrV9LGrNXaOkW2gxZz+CDOzfHsbHyfuwfIxIjv07Qf/HKA6Cc1qbA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.25.0.tgz", + "integrity": "sha512-rRp6X2aNNnvo7Fbqc3olZ0vLb52FlCPPfetr9gy6/M9uQdVYDhJcFOPuRuXtZ8M8X+WpCZBV29BvZFeDqfw8bw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.25.0.tgz", + "integrity": "sha512-T4kXbZNZ/NyklzQ/FWmUnjD4hgmJPrIBazzCZ/E/rF/Ag2IvUsztBT0PN3vTa+DAZ+IbM61TjlIpyJs1R7OdbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block-lowlight": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.26.1.tgz", + "integrity": "sha512-yptuTPYAzVMKHUTwNKYveuu0rYHYyFknPz3O2++PWeeBGxkNB+T6LhwZ/JhXceHcZxzlGyka9r2mXR7pslhugw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/extension-code-block": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "highlight.js": "^11", + "lowlight": "^2 || ^3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.25.0.tgz", + "integrity": "sha512-3gEZlQKUSIRrC6Az8QS7SJi4CvhMWrA7RBChM1aRl9vMNN8Ul7dZZk5StYJGPjL/koTiceMqx9pNmTCBprsbvQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.25.0.tgz", + "integrity": "sha512-eSHqp+iUI2mGVwvIyENP02hi5TSyQ+bdwNwIck6bdzjRvXakm72+8uPfVSLGxRKAQZ0RFtmux8ISazgUqF/oSw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.25.0.tgz", + "integrity": "sha512-hPZ5SNpI14smTz4GpWQXTnxmeICINYiABSgXcsU5V66tik9OtxKwoCSR/gpU35esaAFUVRdjW7+sGkACLZD5AQ==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.25.0.tgz", + "integrity": "sha512-s/3WDbgkvLac88h5iYJLPJCDw8tMhlss1hk9GAo+zzP4h0xfazYie09KrA0CBdfaSOFyeJK3wedzjKZBtdgX4w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.26.1.tgz", + "integrity": "sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.25.0.tgz", + "integrity": "sha512-IrRKRRr7Bhpnq5aue1v5/e5N/eNdVV/THsgqqpLZO48pgN8Wv+TweOZe1Ntg/v8L4QSBC8iGMxxhiJZT8AzSkA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.25.0.tgz", + "integrity": "sha512-y3uJkJv+UngDaDYfcVJ4kx8ivc3Etk5ow6N+47AMCRjUUweQ/CLiJwJ2C7nL7L82zOzVbb/NoR/B3UeE4ts/wQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.25.0.tgz", + "integrity": "sha512-bZovyhdOexB3Cv9ddUogWT+cd3KbnenMIZKhgrJ+R0J27rlOtzeUD9TeIjn4V8Of9mTxm3XDKUZGLgPiriN8Ww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.25.0.tgz", + "integrity": "sha512-FZHmNqvWJ5SHYlUi+Qg3b2C0ZBt82DUDUqM+bqcQqSQu6B0c4IEc3+VHhjAJwEUIO9wX7xk/PsdM4Z5Ex4Lr3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.25.0.tgz", + "integrity": "sha512-jNd+1Fd7wiIbxlS51weBzyDtBEBSVzW0cgzdwOzBYQtPJueRyXNNVERksyinDuVgcfvEWgmNZUylgzu7mehnEg==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.25.0.tgz", + "integrity": "sha512-HLstO/R+dNjIFMXN15bANc8i/+CDpEgtEQhZNHqvSUJH9xQ5op0S05m5VvFI10qnwXNjwwXdhxUYwwjIDCiAgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.25.0.tgz", + "integrity": "sha512-Hlid16nQdDFOGOx6mJT+zPEae2t1dGlJ18pqCqaVMuDnIpNIWmQutJk5QYxGVxr9awd2SpHTpQtdBTqcufbHtw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.25.0.tgz", + "integrity": "sha512-53gpWMPedkWVDp3u/1sLt6vnr3BWz4vArGCmmabLucCI2Yl4R6S/AQ9yj/+jOHvWbXCroCbKtmmwxJl32uGN2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz", + "integrity": "sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.25.0.tgz", + "integrity": "sha512-Z5YBKnv4N6MMD1LEo9XbmWnmdXavZKOOJt/OkXYFZ3KgzB52Z3q3DDfH+NyeCtKKSWqWVxbBHKLnsojDerSf2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.25.0.tgz", + "integrity": "sha512-HlZL86rihpP/R8+dqRrvzSRmiPpx6ctlAKM9PnWT/WRMeI4Y1AUq6PSHLz74wtYO1LH4PXys1ws3n+pLP4Mo6g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.26.1.tgz", + "integrity": "sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.25.0.tgz", + "integrity": "sha512-MKAXqDATEbuFEB1SeeAFy2VbefUMJ9jxQyybpaHjDX+Ik0Ddu+aYuJP/njvLuejXCqhrkS/AorxzmHUC4HNPbQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.25.0.tgz", + "integrity": "sha512-vuzU0pLGQyHqtikAssHn9V61aXLSQERQtn3MUtaJ36fScQg7RClAK5gnIbBt3Ul3VFof8o4xYmcidARc0X/E5A==", + "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.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.25.0.tgz", + "integrity": "sha512-Fc7uj/+goEhvJkH2vYJxXLH1GsUkOcsIR3kUyL0vejNRvpzzd87CI/EiSD2ESJO43czQcsJkiYzY4EC+p8NF9w==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.25.0", + "@tiptap/extension-floating-menu": "^2.25.0", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.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": "2.25.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.25.0.tgz", + "integrity": "sha512-MWt6gEdQ2LPuCqbvNGmS0uA+6rtMGRh3vC0WBNp6rJPAvwS8OPcpraLz61cWjgzeKZBUKODpNA5IZ6gDRyH9LQ==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.25.0", + "@tiptap/extension-blockquote": "^2.25.0", + "@tiptap/extension-bold": "^2.25.0", + "@tiptap/extension-bullet-list": "^2.25.0", + "@tiptap/extension-code": "^2.25.0", + "@tiptap/extension-code-block": "^2.25.0", + "@tiptap/extension-document": "^2.25.0", + "@tiptap/extension-dropcursor": "^2.25.0", + "@tiptap/extension-gapcursor": "^2.25.0", + "@tiptap/extension-hard-break": "^2.25.0", + "@tiptap/extension-heading": "^2.25.0", + "@tiptap/extension-history": "^2.25.0", + "@tiptap/extension-horizontal-rule": "^2.25.0", + "@tiptap/extension-italic": "^2.25.0", + "@tiptap/extension-list-item": "^2.25.0", + "@tiptap/extension-ordered-list": "^2.25.0", + "@tiptap/extension-paragraph": "^2.25.0", + "@tiptap/extension-strike": "^2.25.0", + "@tiptap/extension-text": "^2.25.0", + "@tiptap/extension-text-style": "^2.25.0", + "@tiptap/pm": "^2.25.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.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "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==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "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/@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==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", + "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/type-utils": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.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.35.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.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.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", + "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4" + }, + "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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", + "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.35.1", + "@typescript-eslint/types": "^8.35.1", + "debug": "^4.3.4" + }, + "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 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", + "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.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.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", + "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "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 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", + "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.35.1", + "@typescript-eslint/utils": "8.35.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", + "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "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.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", + "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.35.1", + "@typescript-eslint/tsconfig-utils": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/visitor-keys": "8.35.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.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 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", + "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.35.1", + "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/typescript-estree": "8.35.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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", + "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.35.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", + "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.19", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "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.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "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.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "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/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "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": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "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/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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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/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/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "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.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz", + "integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.14.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.30.1", + "@eslint/plugin-kit": "^0.3.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "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": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "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/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.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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/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/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==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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/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/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "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==", + "dev": true, + "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/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "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/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.3.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", + "integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==", + "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.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, + "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/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/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "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/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/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz", + "integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==", + "license": "MIT" + }, + "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/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==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowlight": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", + "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "fault": "^2.0.0", + "highlight.js": "~11.8.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight/node_modules/highlight.js": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", + "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "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/lucide-react": { + "version": "0.292.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", + "integrity": "sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "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/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-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "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==", + "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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "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/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "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-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "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/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/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", + "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz", + "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", + "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.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", + "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "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.0", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz", + "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==", + "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==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", + "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", + "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.2.5", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", + "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", + "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.1", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz", + "integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==", + "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==", + "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==", + "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.3", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", + "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz", + "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.25.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.10.3", + "prosemirror-view": "^1.39.1" + } + }, + "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==", + "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.10.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", + "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.0.tgz", + "integrity": "sha512-2G3svX0Cr1sJjkD/DYWSe3cfV5VPVTBOxI9XQEGWJDFEpsZb/gh4MV29ctv+OJx2RFX4BLt09i+6zaGM/ldkCw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "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" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, + "node_modules/react-countdown": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/react-countdown/-/react-countdown-2.3.6.tgz", + "integrity": "sha512-ZfX6S08Hb6x6W6eCn1hMDvxPICI/T30fd+gaeVTCR/2cGZ2WJ3f26e4ImNIMX1fHkopJrUdnRpWXP13/D39+gg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">= 15", + "react-dom": ">= 15" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, + "node_modules/react-i18next": { + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.0.tgz", + "integrity": "sha512-W135dB0rDfiFmbMipC17nOhGdttO5mzH8BivY+2ybsQBbXvxWIwl3cmeH3T9d+YPBSJu/ouyJKFJTtkK7rJofw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-image-gallery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/react-image-gallery/-/react-image-gallery-1.4.0.tgz", + "integrity": "sha512-m7xLq7+g6/xh+BhVMAxvRU0132sNcEFglYsVsgthrnItl9VtLE7MuVvVWD9pvzcI+WBP5+p9HvnRwIiyhPkBDg==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-intersection-observer": { + "version": "9.16.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", + "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "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==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-toastify": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz", + "integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-toastify/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.44.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", + "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "dev": true, + "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.44.2", + "@rollup/rollup-android-arm64": "4.44.2", + "@rollup/rollup-darwin-arm64": "4.44.2", + "@rollup/rollup-darwin-x64": "4.44.2", + "@rollup/rollup-freebsd-arm64": "4.44.2", + "@rollup/rollup-freebsd-x64": "4.44.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", + "@rollup/rollup-linux-arm-musleabihf": "4.44.2", + "@rollup/rollup-linux-arm64-gnu": "4.44.2", + "@rollup/rollup-linux-arm64-musl": "4.44.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-gnu": "4.44.2", + "@rollup/rollup-linux-riscv64-musl": "4.44.2", + "@rollup/rollup-linux-s390x-gnu": "4.44.2", + "@rollup/rollup-linux-x64-gnu": "4.44.2", + "@rollup/rollup-linux-x64-musl": "4.44.2", + "@rollup/rollup-win32-arm64-msvc": "4.44.2", + "@rollup/rollup-win32-ia32-msvc": "4.44.2", + "@rollup/rollup-win32-x64-msvc": "4.44.2", + "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==", + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "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": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "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-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, + "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/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "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.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.35.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz", + "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.35.1", + "@typescript-eslint/parser": "8.35.1", + "@typescript-eslint/utils": "8.35.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", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.2.tgz", + "integrity": "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.2", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" + }, + "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.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "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": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "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" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..00e423b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,65 @@ +{ + "name": "picpeak-frontend", + "private": true, + "version": "1.0.61", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:check": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "@tiptap/extension-character-count": "^2.26.1", + "@tiptap/extension-code-block-lowlight": "^2.26.1", + "@tiptap/extension-hard-break": "^2.26.1", + "@tiptap/extension-link": "^2.25.0", + "@tiptap/extension-placeholder": "^2.26.1", + "@tiptap/extension-text-align": "^2.26.1", + "@tiptap/react": "^2.25.0", + "@tiptap/starter-kit": "^2.25.0", + "@types/dompurify": "^3.0.5", + "@types/lodash": "^4.17.20", + "@types/react-google-recaptcha": "^2.1.9", + "axios": "^1.3.2", + "clsx": "^2.0.0", + "date-fns": "^2.29.3", + "dompurify": "^3.2.6", + "i18next": "^25.3.1", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", + "js-cookie": "^3.0.5", + "lodash": "^4.17.21", + "lowlight": "^2.9.0", + "lucide-react": "^0.292.0", + "react": "^18.3.1", + "react-countdown": "^2.3.5", + "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", + "react-i18next": "^15.6.0", + "react-image-gallery": "^1.2.11", + "react-intersection-observer": "^9.4.3", + "react-router-dom": "^6.8.0", + "react-toastify": "^9.1.1", + "tailwind-merge": "^3.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/js-cookie": "^3.0.6", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.5.2", + "autoprefixer": "^10.4.13", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.4.21", + "tailwindcss": "^3.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png new file mode 100644 index 0000000..ac40b82 Binary files /dev/null and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/picpeak-kamera-transparent.png b/frontend/public/picpeak-kamera-transparent.png new file mode 100644 index 0000000..58311b8 Binary files /dev/null and b/frontend/public/picpeak-kamera-transparent.png differ diff --git a/frontend/public/picpeak-logo-transparent.png b/frontend/public/picpeak-logo-transparent.png new file mode 100644 index 0000000..a412674 Binary files /dev/null and b/frontend/public/picpeak-logo-transparent.png differ diff --git a/frontend/public/picpeak-logo.png b/frontend/public/picpeak-logo.png new file mode 100644 index 0000000..8ce30c9 Binary files /dev/null and b/frontend/public/picpeak-logo.png differ diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..cd0f254 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,153 @@ +import { useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { analyticsService } from './services/analytics.service'; + +import { GalleryAuthProvider, MaintenanceProvider } from './contexts'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { GalleryPage } from './pages/GalleryPage'; +import { PreviewPage } from './pages/gallery/PreviewPage'; +import { LegalPage } from './pages/public/LegalPage'; +import { + AdminLoginPage, + AdminDashboard, + EventsListPage, + CreateEventPageEnhanced as CreateEventPage, + EventDetailsPage, + EmailConfigPage, + ArchivesPage, + AnalyticsPage, + BrandingPage, + SettingsPage, + CMSPage +} from './pages/admin'; +import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced'; +import { AdminLayout, AdminAuthWrapper } from './components/admin'; +import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common'; +import { MaintenanceWrapper } from './components/MaintenanceWrapper'; +import { GlobalThemeProvider } from './components/GlobalThemeProvider'; +import { getApiBaseUrl } from './utils/url'; + +// Create a client +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + }, + }, +}); + +function App() { + // Initialize Umami Analytics based on settings + useEffect(() => { + const initializeAnalytics = async () => { + const umamiUrl = import.meta.env.VITE_UMAMI_URL; + const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID; + + if (umamiUrl && umamiWebsiteId) { + try { + // Fetch public settings to check if analytics is enabled + const response = await fetch(`${getApiBaseUrl()}/public/settings`); + const settings = await response.json(); + + // Only initialize if analytics is enabled in settings + if (settings.enable_analytics !== false) { + analyticsService.initialize({ + websiteId: umamiWebsiteId, + hostUrl: umamiUrl, + autoTrack: true, + doNotTrack: true + }); + } + } catch (error) { + console.error('Failed to fetch settings for analytics:', error); + // Initialize analytics anyway if settings fetch fails + analyticsService.initialize({ + websiteId: umamiWebsiteId, + hostUrl: umamiUrl, + autoTrack: true, + doNotTrack: true + }); + } + } + }; + + initializeAnalytics(); + }, []); + + return ( + + + + + + + + + + + {/* Public gallery routes */} + } /> + + + + } /> + + {/* Admin routes - wrap with AdminAuthProvider */} + }> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + {/* Public legal pages */} + } /> + } /> + } /> + + {/* Default redirect */} + } /> + + + + + {/* Offline indicator */} + + + {/* Toast notifications */} + + + + + + + ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/GlobalThemeProvider.tsx b/frontend/src/components/GlobalThemeProvider.tsx new file mode 100644 index 0000000..d3fb8b3 --- /dev/null +++ b/frontend/src/components/GlobalThemeProvider.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useRef } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTheme } from '../contexts/ThemeContext'; +import { api } from '../config/api'; + +interface GlobalThemeProviderProps { + children: React.ReactNode; +} + +export const GlobalThemeProvider: React.FC = ({ children }) => { + const { setTheme } = useTheme(); + const themeAppliedRef = useRef(false); + + // Fetch public settings including theme config + const { data: settingsData } = useQuery({ + queryKey: ['global-theme-settings'], + queryFn: async () => { + const response = await api.get('/public/settings'); + return response.data; + }, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }); + + // Apply global theme when settings are loaded (but not on gallery pages) + useEffect(() => { + // Skip if we're on a gallery page - gallery pages handle their own themes + const isGalleryPage = window.location.pathname.includes('/gallery/'); + + if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) { + themeAppliedRef.current = true; + setTheme(settingsData.theme_config); + } + }, [settingsData, setTheme]); + + return <>{children}; +}; \ No newline at end of file diff --git a/frontend/src/components/MaintenanceMode.tsx b/frontend/src/components/MaintenanceMode.tsx new file mode 100644 index 0000000..83d2ef4 --- /dev/null +++ b/frontend/src/components/MaintenanceMode.tsx @@ -0,0 +1,113 @@ +import React, { useEffect } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { api } from '../config/api'; +import { buildResourceUrl } from '../utils/url'; + +interface BrandingSettings { + branding_company_name?: string; + branding_company_tagline?: string; + branding_support_email?: string; + branding_footer_text?: string; + branding_favicon_url?: string; + branding_logo_url?: string; + default_language?: string; +} + +export const MaintenanceMode: React.FC = () => { + const { t, i18n } = useTranslation(); + + // Fetch branding settings + const { data: settings } = useQuery({ + queryKey: ['public-settings-maintenance'], + queryFn: async () => { + try { + const response = await api.get('/public/settings'); + return response.data; + } catch (error) { + // Return empty object if settings can't be fetched + return {}; + } + }, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + retry: false, // Don't retry on failure + }); + + // Set language based on system settings + useEffect(() => { + if (settings?.default_language && settings.default_language !== i18n.language) { + i18n.changeLanguage(settings.default_language); + } + }, [settings?.default_language, i18n]); + + return ( +
    + {/* Header with branding - Always show, with PicPeak logo as fallback */} +
    +
    +
    + {settings?.branding_company_name + {settings?.branding_company_name && settings.branding_company_name !== 'PicPeak' && ( +
    +

    {settings.branding_company_name}

    + {settings.branding_company_tagline && ( +

    {settings.branding_company_tagline}

    + )} +
    + )} +
    +
    +
    + + {/* Main content */} +
    +
    +
    + +
    + +

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

    + +

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

    + + {settings?.branding_support_email && ( +

    + {t('maintenance.urgentMatters')}{' '} + + {settings.branding_support_email} + +

    + )} +
    +
    + + {/* Footer */} + {settings?.branding_footer_text && ( +
    +
    +

    + {settings.branding_footer_text} +

    +
    +
    + )} +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/components/MaintenanceWrapper.tsx b/frontend/src/components/MaintenanceWrapper.tsx new file mode 100644 index 0000000..c6b34f0 --- /dev/null +++ b/frontend/src/components/MaintenanceWrapper.tsx @@ -0,0 +1,59 @@ +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { MaintenanceMode } from './MaintenanceMode'; +import { useMaintenanceMode } from '../contexts/MaintenanceContext'; +import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api'; + +interface MaintenanceWrapperProps { + children: React.ReactNode; +} + +export const MaintenanceWrapper: React.FC = ({ children }) => { + const location = useLocation(); + const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode(); + + // Check if current route is admin route + const isAdminRoute = location.pathname.startsWith('/admin'); + const hasAdminAuth = !!getAuthToken(true); + + // Register the maintenance mode callback + useEffect(() => { + setMaintenanceModeCallback((enabled: boolean) => { + setMaintenanceMode(enabled); + }); + }, [setMaintenanceMode]); + + // Check maintenance mode on mount and when location changes + useQuery({ + queryKey: ['maintenance-check', location.pathname], + queryFn: async () => { + try { + // Make a lightweight request to check maintenance status + await api.get('/public/settings'); + // If successful, maintenance mode is off + setMaintenanceMode(false); + return { maintenance: false }; + } catch (error: any) { + if (error.response?.status === 503) { + // Only set maintenance mode for non-admin routes or unauthenticated admin routes + if (!isAdminRoute || !hasAdminAuth) { + setMaintenanceMode(true); + return { maintenance: true }; + } + } + return { maintenance: false }; + } + }, + staleTime: 30000, // Check every 30 seconds + retry: false, // Don't retry on failure + enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance + }); + + // Show maintenance page if in maintenance mode and not on admin route with auth + if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) { + return ; + } + + return <>{children}; +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminAuthWrapper.tsx b/frontend/src/components/admin/AdminAuthWrapper.tsx new file mode 100644 index 0000000..4647fff --- /dev/null +++ b/frontend/src/components/admin/AdminAuthWrapper.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { AdminAuthProvider } from '../../contexts'; + +export const AdminAuthWrapper: React.FC = () => { + return ( + + + + ); +}; + +AdminAuthWrapper.displayName = 'AdminAuthWrapper'; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminAuthenticatedImage.tsx b/frontend/src/components/admin/AdminAuthenticatedImage.tsx new file mode 100644 index 0000000..99526b7 --- /dev/null +++ b/frontend/src/components/admin/AdminAuthenticatedImage.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../../config/api'; + +interface AdminAuthenticatedImageProps extends React.ImgHTMLAttributes { + src: string; + fallback?: React.ReactNode; +} + +export const AdminAuthenticatedImage: React.FC = ({ + src, + fallback, + alt, + ...props +}) => { + const [imageSrc, setImageSrc] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + let cancelled = false; + + const loadImage = async () => { + try { + setLoading(true); + setError(false); + + // Make authenticated request to get the image + const response = await api.get(src, { + responseType: 'blob', + }); + + if (!cancelled) { + // Create object URL from blob + const imageUrl = URL.createObjectURL(response.data); + setImageSrc(imageUrl); + setLoading(false); + } + } catch (err: any) { + // Image loading failed - handled by error state + if (!cancelled) { + setError(true); + setLoading(false); + } + } + }; + + if (src) { + loadImage(); + } + + // Cleanup function + return () => { + cancelled = true; + if (imageSrc) { + URL.revokeObjectURL(imageSrc); + } + }; + }, [src]); + + if (loading) { + return ( +
    + ); + } + + if (error) { + return fallback ? ( + <>{fallback} + ) : ( +
    + Failed to load +
    + ); + } + + return {alt}; +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx new file mode 100644 index 0000000..7f95d76 --- /dev/null +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -0,0 +1,250 @@ +import React, { useState, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useAdminAuth } from '../../contexts'; +import { useOnClickOutside } from '../../hooks/useOnClickOutside'; +import { PasswordChangeModal } from './PasswordChangeModal'; +import { LanguageSelector } from '../common'; +import { notificationsService } from '../../services/notifications.service'; +import { toast } from 'react-toastify'; + +interface AdminHeaderProps { + onMenuClick: () => void; +} + +export const AdminHeader: React.FC = ({ onMenuClick }) => { + const navigate = useNavigate(); + const { user, logout } = useAdminAuth(); + const { t } = useTranslation(); + const { format } = useLocalizedDate(); + const { formatTimeAgo } = useLocalizedTimeAgo(); + const [showUserMenu, setShowUserMenu] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const queryClient = useQueryClient(); + + const userMenuRef = useRef(null); + const notificationRef = useRef(null); + + useOnClickOutside(userMenuRef, () => setShowUserMenu(false)); + useOnClickOutside(notificationRef, () => setShowNotifications(false)); + + const handleLogout = () => { + logout(); + navigate('/admin/login'); + }; + + // Fetch notifications + const { data: notificationsData } = useQuery({ + queryKey: ['notifications', showNotifications], + queryFn: () => notificationsService.getNotifications(showNotifications, 20), + refetchInterval: 60000, // Refetch every minute + }); + + // Mark all as read mutation + const markAllAsReadMutation = useMutation({ + mutationFn: notificationsService.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + toast.success(t('admin.notificationToasts.markedAllRead')); + }, + }); + + // Clear old notifications mutation + const clearOldMutation = useMutation({ + mutationFn: notificationsService.clearOldNotifications, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount })); + }, + }); + + const notifications = notificationsData?.notifications || []; + const unreadCount = notificationsData?.unreadCount || 0; + + return ( +
    +
    +
    + {/* Left side - Menu button and Date */} +
    + + + {/* Date display */} +
    +

    + {format(new Date(), 'PPPP')} +

    +
    +
    + + {/* Center - Logo and PicPeak text */} +
    + PicPeak + PicPeak +
    + + {/* Right side actions */} +
    + {/* Language Selector */} + + + {/* Notifications */} +
    + + + {/* Notifications dropdown */} + {showNotifications && ( +
    +
    +

    {t('admin.notifications')}

    +
    + {unreadCount > 0 && ( + + )} + +
    +
    +
    + {notifications.length === 0 ? ( +
    + {t('admin.noNotificationsMessage')} +
    + ) : ( + notifications.map((notification) => { + const style = notificationsService.getNotificationStyle(notification.type); + return ( +
    +
    +
    + +
    +
    +

    + {notificationsService.formatNotificationMessage(notification)} +

    +

    + {formatTimeAgo(notification.createdAt)} +

    +
    +
    +
    + ); + }) + )} +
    + {notifications.length > 0 && ( +
    + +
    + )} +
    + )} +
    + + {/* User menu */} +
    + + + {/* User dropdown */} + {showUserMenu && ( +
    +
    +

    {user?.username}

    +

    {user?.email}

    +
    + + + +
    + )} +
    +
    +
    +
    + + {/* Password Change Modal */} + setShowPasswordModal(false)} + /> +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx new file mode 100644 index 0000000..c99ed93 --- /dev/null +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { Outlet, Navigate } from 'react-router-dom'; + +import { useAdminAuth } from '../../contexts'; +import { useSessionTimeout } from '../../hooks/useSessionTimeout'; +import { AdminSidebar } from './AdminSidebar'; +import { AdminHeader } from './AdminHeader'; +import { MaintenanceBanner } from './MaintenanceBanner'; + +export const AdminLayout: React.FC = () => { + const { isAuthenticated, isLoading } = useAdminAuth(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + // Handle session timeout + useSessionTimeout(); + + if (isLoading) { + return ( +
    +
    +
    +

    Loading...

    +
    +
    + ); + } + + if (!isAuthenticated) { + return ; + } + + return ( +
    + {/* Mobile sidebar backdrop */} + {sidebarOpen && ( +
    setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + setSidebarOpen(false)} /> + + {/* Main content */} +
    + {/* Header */} + setSidebarOpen(true)} /> + + {/* Maintenance mode banner */} + + + {/* Page content */} +
    + +
    +
    +
    + ); +}; + +AdminLayout.displayName = 'AdminLayout'; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx new file mode 100644 index 0000000..e627a3f --- /dev/null +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -0,0 +1,251 @@ +import React, { useState } from 'react'; +import { Check, Download, Trash2, Eye, Package } from 'lucide-react'; +import { toast } from 'react-toastify'; + +import { AdminPhoto } from '../../services/photos.service'; +import { photosService } from '../../services/photos.service'; +import { Button } from '../common'; +import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; + +interface AdminPhotoGridProps { + photos: AdminPhoto[]; + eventId: number; + onPhotoClick: (photo: AdminPhoto, index: number) => void; + onPhotosDeleted: () => void; +} + +export const AdminPhotoGrid: React.FC = ({ + photos, + eventId, + onPhotoClick, + onPhotosDeleted +}) => { + const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deletingPhotoId, setDeletingPhotoId] = useState(null); + + const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => { + if (e) { + e.stopPropagation(); + } + + const newSelected = new Set(selectedPhotos); + if (newSelected.has(photoId)) { + newSelected.delete(photoId); + } else { + newSelected.add(photoId); + } + setSelectedPhotos(newSelected); + }; + + const handleSelectAll = () => { + if (selectedPhotos.size === photos.length) { + setSelectedPhotos(new Set()); + } else { + setSelectedPhotos(new Set(photos.map(p => p.id))); + } + }; + + const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => { + e.stopPropagation(); + + if (!confirm(`Are you sure you want to delete "${photo.filename}"?`)) { + return; + } + + setDeletingPhotoId(photo.id); + try { + await photosService.deletePhoto(eventId, photo.id); + toast.success('Photo deleted successfully'); + onPhotosDeleted(); + } catch (error) { + toast.error('Failed to delete photo'); + } finally { + setDeletingPhotoId(null); + } + }; + + const handleDeleteSelected = async () => { + if (selectedPhotos.size === 0) return; + + const count = selectedPhotos.size; + if (!confirm(`Are you sure you want to delete ${count} photo${count > 1 ? 's' : ''}?`)) { + return; + } + + setIsDeleting(true); + try { + await photosService.deletePhotos(eventId, Array.from(selectedPhotos)); + toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`); + setSelectedPhotos(new Set()); + setIsSelectionMode(false); + onPhotosDeleted(); + } catch (error) { + toast.error('Failed to delete photos'); + } finally { + setIsDeleting(false); + } + }; + + const handleDownload = async (photo: AdminPhoto, e: React.MouseEvent) => { + e.stopPropagation(); + try { + await photosService.downloadPhoto(eventId, photo.id, photo.filename); + toast.success('Download started'); + } catch (error) { + toast.error('Failed to download photo'); + } + }; + + const toggleSelectionMode = () => { + setIsSelectionMode(!isSelectionMode); + if (isSelectionMode) { + setSelectedPhotos(new Set()); + } + }; + + return ( +
    + {/* Action Bar */} +
    +
    + + + {isSelectionMode && ( + <> + + + {selectedPhotos.size > 0 && ( + <> + + {selectedPhotos.size} selected + + + + )} + + )} +
    + +
    + {photos.length} photo{photos.length !== 1 ? 's' : ''} +
    +
    + + {/* Photo Grid */} +
    + {photos.map((photo, index) => ( +
    isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)} + > + {/* Selection Checkbox */} + {isSelectionMode && ( +
    +
    + {selectedPhotos.has(photo.id) && ( + + )} +
    +
    + )} + + {/* Thumbnail */} +
    + {photo.thumbnail_url ? ( + + +
    + } + /> + ) : ( +
    + +
    + )} +
    + + {/* Overlay with actions */} +
    +
    +

    + {photo.filename} +

    +

    + {photosService.formatBytes(photo.size)} +

    + + {!isSelectionMode && ( +
    + + +
    + )} +
    +
    + + {/* Category Badge */} + {photo.category_name && ( +
    + + {photo.category_name} + +
    + )} +
    + ))} +
    + + {photos.length === 0 && ( +
    +

    No photos uploaded yet

    +
    + )} +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx new file mode 100644 index 0000000..868abf2 --- /dev/null +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -0,0 +1,269 @@ +import React, { useState } from 'react'; +import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react'; +import { format } from 'date-fns'; +import { toast } from 'react-toastify'; + +import { AdminPhoto } from '../../services/photos.service'; +import { photosService } from '../../services/photos.service'; +import { Button } from '../common'; +import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; + +interface AdminPhotoViewerProps { + photos: AdminPhoto[]; + initialIndex: number; + eventId: number; + onClose: () => void; + onPhotoDeleted: () => void; + categories: Array<{ id: number; name: string; slug: string }>; +} + +export const AdminPhotoViewer: React.FC = ({ + photos, + initialIndex, + eventId, + onClose, + onPhotoDeleted, + categories +}) => { + const [currentIndex, setCurrentIndex] = useState(initialIndex); + const [isDeleting, setIsDeleting] = useState(false); + const [showCategoryMenu, setShowCategoryMenu] = useState(false); + + const currentPhoto = photos[currentIndex]; + + const goToPrevious = () => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); + }; + + const goToNext = () => { + setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0)); + }; + + const handleDelete = async () => { + if (!confirm(`Are you sure you want to delete "${currentPhoto.filename}"?`)) { + return; + } + + setIsDeleting(true); + try { + await photosService.deletePhoto(eventId, currentPhoto.id); + toast.success('Photo deleted successfully'); + + // Close viewer if this was the last photo + if (photos.length === 1) { + onClose(); + } else { + // Move to next photo if available, otherwise previous + if (currentIndex === photos.length - 1) { + setCurrentIndex(currentIndex - 1); + } + } + + onPhotoDeleted(); + } catch (error) { + toast.error('Failed to delete photo'); + } finally { + setIsDeleting(false); + } + }; + + const handleDownload = async () => { + try { + await photosService.downloadPhoto(eventId, currentPhoto.id, currentPhoto.filename); + toast.success('Download started'); + } catch (error) { + toast.error('Failed to download photo'); + } + }; + + const handleCategoryChange = async (categoryId: number | null) => { + try { + await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId); + toast.success('Category updated'); + setShowCategoryMenu(false); + // Trigger refresh to update the photo data + onPhotoDeleted(); // This will refresh the photos list + } catch (error) { + toast.error('Failed to update category'); + } + }; + + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onClose(); + break; + case 'ArrowLeft': + goToPrevious(); + break; + case 'ArrowRight': + goToNext(); + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [currentIndex]); + + return ( +
    + {/* Close button */} + + + {/* Navigation */} + + + + + {/* Main content */} +
    + {/* Image */} +
    + +
    + +

    Failed to load image

    +
    +
    + } + /> +
    + + {/* Sidebar */} +
    +

    {currentPhoto.filename}

    + + {/* Actions */} +
    + + +
    + + {/* Category */} +
    +
    + + + Category + + +
    +

    + {currentPhoto.category_name || 'Uncategorized'} +

    + + {showCategoryMenu && ( +
    + + {categories.map(cat => ( + + ))} +
    + )} +
    + + {/* Metadata */} +
    +
    + + + File Size + +

    {photosService.formatBytes(currentPhoto.size)}

    +
    + +
    + + + Uploaded + +

    + {format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')} +

    +
    + + {currentPhoto.view_count !== undefined && ( +
    + + + Views + +

    {currentPhoto.view_count}

    +
    + )} + + {currentPhoto.download_count !== undefined && ( +
    + + + Downloads + +

    {currentPhoto.download_count}

    +
    + )} +
    + + {/* Navigation info */} +
    +

    + {currentIndex + 1} of {photos.length} +

    +
    +
    +
    +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx new file mode 100644 index 0000000..4b1f136 --- /dev/null +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { NavLink, useLocation } from 'react-router-dom'; +import { + LayoutDashboard, + Calendar, + Mail, + Archive, + BarChart3, + Settings, + X, + Palette, + FileText +} from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { settingsService } from '../../services/settings.service'; +import { VersionInfo } from './VersionInfo'; + +interface AdminSidebarProps { + isOpen: boolean; + onClose: () => void; +} + +interface NavItem { + nameKey: string; + href: string; + icon: React.ComponentType<{ className?: string }>; +} + +const navigation: NavItem[] = [ + { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, + { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar }, + { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive }, + { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 }, + { nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail }, + { nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette }, + { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings }, + { nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText }, +]; + +export const AdminSidebar: React.FC = ({ isOpen, onClose }) => { + const location = useLocation(); + const { t } = useTranslation(); + + return ( +
    +
    + {/* Brand */} +
    +
    + {t('admin.title')} +
    + +
    + + {/* Navigation */} + + + {/* Bottom section - sticky to bottom */} +
    + {/* Version Info */} + + + {/* Storage Info */} + +
    +
    +
    + ); +}; + +const StorageInfo: React.FC = () => { + const { t } = useTranslation(); + const { data: storageInfo } = useQuery({ + queryKey: ['storage-info'], + queryFn: () => settingsService.getStorageInfo(), + refetchInterval: 60000 // Refresh every minute + }); + + if (!storageInfo) { + return ( +
    +
    +
    +
    +
    + ); + } + + const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100); + + return ( +
    +
    +
    + {t('admin.storageUsed')} + + {settingsService.formatBytes(storageInfo.total_used)} + +
    +
    +
    +
    +

    + {t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })} +

    +
    +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/components/admin/BulkArchiveModal.tsx b/frontend/src/components/admin/BulkArchiveModal.tsx new file mode 100644 index 0000000..fc039a6 --- /dev/null +++ b/frontend/src/components/admin/BulkArchiveModal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Archive, AlertTriangle, X } from 'lucide-react'; +import { Button, Card } from '../common'; +import type { Event } from '../../types'; + +interface BulkArchiveModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + selectedEvents: Event[]; + isLoading?: boolean; +} + +export const BulkArchiveModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + selectedEvents, + isLoading = false, +}) => { + if (!isOpen) return null; + + return ( +
    + +
    +
    +

    Confirm Bulk Archive

    + +
    + +
    +
    + +
    +

    + You are about to archive {selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}. + This action will: +

    +
      +
    • Create a ZIP archive of all photos for each event
    • +
    • Make the galleries inaccessible to guests
    • +
    • Remove the events from active listings
    • +
    • Free up storage space by compressing photos
    • +
    +
    +
    + +
    +
    +

    Events to be archived:

    +
      + {selectedEvents.map((event) => ( +
    • + โ€ข {event.event_name} ({event.event_type}) +
    • + ))} +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    + ); +}; + +BulkArchiveModal.displayName = 'BulkArchiveModal'; \ No newline at end of file diff --git a/frontend/src/components/admin/CMSEditor.tsx b/frontend/src/components/admin/CMSEditor.tsx new file mode 100644 index 0000000..78002bb --- /dev/null +++ b/frontend/src/components/admin/CMSEditor.tsx @@ -0,0 +1,571 @@ +import React, { useState, useCallback } from 'react'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import HardBreak from '@tiptap/extension-hard-break'; +import Placeholder from '@tiptap/extension-placeholder'; +import CharacterCount from '@tiptap/extension-character-count'; +import TextAlign from '@tiptap/extension-text-align'; +import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; +import { lowlight } from 'lowlight'; +import { + Bold, + Italic, + List, + ListOrdered, + Link as LinkIcon, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + Quote, + Code, + Code2, + Minus, + Undo, + Redo, + RemoveFormatting, + AlignLeft, + AlignCenter, + AlignRight, + AlignJustify, + Eye, + Edit3, + Columns, + Maximize2, + HelpCircle, + Save +} from 'lucide-react'; +import { Button } from '../common'; +import DOMPurify from 'dompurify'; +import '../../styles/prose-overrides.css'; + +interface CMSEditorProps { + content: string; + onChange: (content: string) => void; + onSave?: () => void; + isSaving?: boolean; +} + +type ViewMode = 'edit' | 'preview' | 'split'; + +export const CMSEditor: React.FC = ({ content, onChange, onSave, isSaving }) => { + const [linkUrl, setLinkUrl] = useState(''); + const [showLinkDialog, setShowLinkDialog] = useState(false); + const [viewMode, setViewMode] = useState('edit'); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showHelp, setShowHelp] = useState(false); + const [wordCount, setWordCount] = useState(0); + const [charCount, setCharCount] = useState(0); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + hardBreak: false, // We'll use the separate HardBreak extension + codeBlock: false, // We'll use CodeBlockLowlight instead + }), + HardBreak.configure({ + keepMarks: true, + HTMLAttributes: { + class: 'hard-break', + }, + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { + target: '_blank', + rel: 'noopener noreferrer', + }, + }), + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + defaultAlignment: 'left', + }), + CodeBlockLowlight.configure({ + lowlight, + HTMLAttributes: { + class: 'hljs', + }, + }), + Placeholder.configure({ + placeholder: 'Start typing your content here...', + }), + CharacterCount.configure({ + limit: null, + }), + ], + content, + onUpdate: ({ editor }) => { + onChange(editor.getHTML()); + updateCounts(editor); + }, + onCreate: ({ editor }) => { + updateCounts(editor); + }, + }); + + const updateCounts = useCallback((editor: any) => { + const text = editor.state.doc.textContent; + setCharCount(editor.storage.characterCount.characters()); + setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length); + }, []); + + // Update editor content when prop changes + React.useEffect(() => { + if (editor && content !== editor.getHTML()) { + editor.commands.setContent(content); + } + }, [content, editor]); + + if (!editor) { + return null; + } + + const addLink = () => { + if (linkUrl) { + editor.chain().focus().setLink({ href: linkUrl }).run(); + setLinkUrl(''); + setShowLinkDialog(false); + } + }; + + const MenuButton: React.FC<{ + onClick: () => void; + active?: boolean; + children: React.ReactNode; + title: string; + disabled?: boolean; + }> = ({ onClick, active, children, title, disabled }) => ( + + ); + + const toggleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + + const getPreviewContent = () => { + return DOMPurify.sanitize(editor?.getHTML() || '', { + ALLOWED_TAGS: [ + 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong', + 'code', 'pre', 'hr', 'div', 'span' + ], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'], + ALLOW_DATA_ATTR: false, + KEEP_CONTENT: true, + ADD_TAGS: ['br'], // Explicitly allow br tags + ADD_ATTR: ['style'], // Allow style for text alignment + }); + }; + + return ( +
    +
    + {/* Top Toolbar */} +
    + {/* View Mode Controls */} +
    +
    + + + +
    + +
    + {onSave && ( + + )} + + setShowHelp(true)} + title="Help & Keyboard Shortcuts" + > + + + + + + +
    +
    + + {/* Formatting Toolbar */} + {viewMode !== 'preview' && ( +
    + editor.chain().focus().toggleHeading({ level: 1 }).run()} + active={editor.isActive('heading', { level: 1 })} + title="Heading 1 (Ctrl+Alt+1)" + > + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + active={editor.isActive('heading', { level: 2 })} + title="Heading 2 (Ctrl+Alt+2)" + > + + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} + active={editor.isActive('heading', { level: 3 })} + title="Heading 3 (Ctrl+Alt+3)" + > + + + + editor.chain().focus().toggleHeading({ level: 4 }).run()} + active={editor.isActive('heading', { level: 4 })} + title="Heading 4 (Ctrl+Alt+4)" + > + + + + editor.chain().focus().toggleHeading({ level: 5 }).run()} + active={editor.isActive('heading', { level: 5 })} + title="Heading 5 (Ctrl+Alt+5)" + > + + + + editor.chain().focus().toggleHeading({ level: 6 }).run()} + active={editor.isActive('heading', { level: 6 })} + title="Heading 6 (Ctrl+Alt+6)" + > + + + +
    + + editor.chain().focus().toggleBold().run()} + active={editor.isActive('bold')} + title="Bold (Ctrl+B)" + > + + + + editor.chain().focus().toggleItalic().run()} + active={editor.isActive('italic')} + title="Italic (Ctrl+I)" + > + + + + editor.chain().focus().toggleCode().run()} + active={editor.isActive('code')} + title="Inline Code (Ctrl+E)" + > + + + + editor.chain().focus().toggleCodeBlock().run()} + active={editor.isActive('codeBlock')} + title="Code Block (Ctrl+Alt+C)" + > + + + +
    + + editor.chain().focus().toggleBulletList().run()} + active={editor.isActive('bulletList')} + title="Bullet List (Ctrl+Shift+8)" + > + + + + editor.chain().focus().toggleOrderedList().run()} + active={editor.isActive('orderedList')} + title="Numbered List (Ctrl+Shift+9)" + > + + + + editor.chain().focus().toggleBlockquote().run()} + active={editor.isActive('blockquote')} + title="Blockquote (Ctrl+Shift+B)" + > + + + +
    + + setShowLinkDialog(true)} + active={editor.isActive('link')} + title="Add Link (Ctrl+K)" + > + + + + editor.chain().focus().setHorizontalRule().run()} + title="Horizontal Rule" + > + + + +
    + + editor.chain().focus().setTextAlign('left').run()} + active={editor.isActive({ textAlign: 'left' })} + title="Align Left" + > + + + + editor.chain().focus().setTextAlign('center').run()} + active={editor.isActive({ textAlign: 'center' })} + title="Align Center" + > + + + + editor.chain().focus().setTextAlign('right').run()} + active={editor.isActive({ textAlign: 'right' })} + title="Align Right" + > + + + + editor.chain().focus().setTextAlign('justify').run()} + active={editor.isActive({ textAlign: 'justify' })} + title="Justify" + > + + + +
    + + editor.chain().focus().clearNodes().unsetAllMarks().run()} + title="Clear Formatting" + > + + + +
    + + editor.chain().focus().undo().run()} + disabled={!editor.can().undo()} + title="Undo (Ctrl+Z)" + > + + + + editor.chain().focus().redo().run()} + disabled={!editor.can().redo()} + title="Redo (Ctrl+Y)" + > + + +
    + )} +
    + + {/* Link Dialog */} + {showLinkDialog && ( +
    + setLinkUrl(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && addLink()} + placeholder="Enter URL..." + className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
    + )} + + {/* Editor Content Area */} +
    + {/* Editor */} + {viewMode !== 'preview' && ( +
    + +
    + )} + + {/* Preview */} + {viewMode !== 'edit' && ( +
    +
    +
    + )} +
    + + {/* Status Bar */} +
    +
    + {wordCount} words + {charCount} characters +
    +
    + Press Shift+Enter for line break, Enter for new paragraph +
    +
    +
    + + {/* Help Modal */} + {showHelp && ( +
    +
    +
    +

    Editor Help & Keyboard Shortcuts

    + +
    +
    +

    Text Formatting

    +
    +
    Ctrl+B - Bold
    +
    Ctrl+I - Italic
    +
    Ctrl+E - Inline code
    +
    Ctrl+K - Add link
    +
    +
    + +
    +

    Headings

    +
    +
    Ctrl+Alt+1 - Heading 1
    +
    Ctrl+Alt+2 - Heading 2
    +
    Ctrl+Alt+3 - Heading 3
    +
    Ctrl+Alt+4 - Heading 4
    +
    Ctrl+Alt+5 - Heading 5
    +
    Ctrl+Alt+6 - Heading 6
    +
    +
    + +
    +

    Lists & Blocks

    +
    +
    Ctrl+Shift+8 - Bullet list
    +
    Ctrl+Shift+9 - Numbered list
    +
    Ctrl+Shift+B - Blockquote
    +
    Ctrl+Alt+C - Code block
    +
    +
    + +
    +

    Text Alignment

    +
    +
    Click alignment buttons in toolbar
    +
    Works on paragraphs and headings
    +
    +
    + +
    +

    Line Breaks

    +
    +
    Enter - New paragraph
    +
    Shift+Enter - Line break (preserves formatting)
    +
    +
    + +
    +

    Navigation

    +
    +
    Ctrl+Z - Undo
    +
    Ctrl+Y - Redo
    +
    +
    +
    + +
    + +
    +
    +
    +
    + )} +
    + ); +}; + +CMSEditor.displayName = 'CMSEditor'; \ No newline at end of file diff --git a/frontend/src/components/admin/CategoryManager.tsx b/frontend/src/components/admin/CategoryManager.tsx new file mode 100644 index 0000000..ccc5b65 --- /dev/null +++ b/frontend/src/components/admin/CategoryManager.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { categoriesService, type PhotoCategory } from '../../services/categories.service'; +import { Button } from '../common'; + +export const CategoryManager: React.FC = () => { + const queryClient = useQueryClient(); + const [isAdding, setIsAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + const [newCategoryName, setNewCategoryName] = useState(''); + const [editingName, setEditingName] = useState(''); + + // Fetch global categories + const { data: categories = [], isLoading } = useQuery({ + queryKey: ['global-categories'], + queryFn: categoriesService.getGlobalCategories, + }); + + // Create category mutation + const createMutation = useMutation({ + mutationFn: (name: string) => + categoriesService.createCategory({ name, is_global: true }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['global-categories'] }); + toast.success('Category created successfully'); + setNewCategoryName(''); + setIsAdding(false); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to create category'); + }, + }); + + // Update category mutation + const updateMutation = useMutation({ + mutationFn: ({ id, name }: { id: number; name: string }) => + categoriesService.updateCategory(id, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['global-categories'] }); + toast.success('Category updated successfully'); + setEditingId(null); + setEditingName(''); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to update category'); + }, + }); + + // Delete category mutation + const deleteMutation = useMutation({ + mutationFn: categoriesService.deleteCategory, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['global-categories'] }); + toast.success('Category deleted successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Failed to delete category'); + }, + }); + + const handleCreate = () => { + if (newCategoryName.trim()) { + createMutation.mutate(newCategoryName.trim()); + } + }; + + const handleUpdate = (id: number) => { + if (editingName.trim()) { + updateMutation.mutate({ id, name: editingName.trim() }); + } + }; + + const handleDelete = (category: PhotoCategory) => { + if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) { + deleteMutation.mutate(category.id); + } + }; + + const startEdit = (category: PhotoCategory) => { + setEditingId(category.id); + setEditingName(category.name); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditingName(''); + }; + + if (isLoading) { + return ( +
    + +
    + ); + } + + return ( +
    +
    +

    Photo Categories

    + {!isAdding && ( + + )} +
    + + {/* Add new category form */} + {isAdding && ( +
    + setNewCategoryName(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleCreate()} + placeholder="Category name" + className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
    + )} + + {/* Categories list */} +
    + {categories.length === 0 ? ( +

    + No categories yet. Create your first category to organize photos. +

    + ) : ( + categories.map((category) => ( +
    + {editingId === category.id ? ( +
    + setEditingName(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') handleUpdate(category.id); + if (e.key === 'Escape') cancelEdit(); + }} + className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500" + autoFocus + /> + + +
    + ) : ( + <> +
    +

    {category.name}

    +

    /{category.slug}

    +
    +
    + + +
    + + )} +
    + )) + )} +
    +
    + ); +}; + +CategoryManager.displayName = 'CategoryManager'; \ No newline at end of file diff --git a/frontend/src/components/admin/EmailPreviewModal.tsx b/frontend/src/components/admin/EmailPreviewModal.tsx new file mode 100644 index 0000000..b5ffb35 --- /dev/null +++ b/frontend/src/components/admin/EmailPreviewModal.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { X, Mail, FileText } from 'lucide-react'; +import { Button, Card } from '../common'; + +interface EmailPreviewModalProps { + isOpen: boolean; + onClose: () => void; + subject: string; + htmlContent: string; + textContent?: string; +} + +export const EmailPreviewModal: React.FC = ({ + isOpen, + onClose, + subject, + htmlContent, + textContent +}) => { + const [viewMode, setViewMode] = React.useState<'html' | 'text'>('html'); + + if (!isOpen) return null; + + return ( +
    + + {/* Header */} +
    +
    + +

    Email Preview

    +
    + +
    + + {/* Subject */} +
    +

    Subject:

    +

    {subject}

    +
    + + {/* View mode toggle */} +
    +
    + + {textContent && ( + + )} +
    +
    + + {/* Content */} +
    + {viewMode === 'html' ? ( +
    +