commit b79e599640c0cac2ace727f546809bf7f85b2cef Author: lowcarbdev Date: Tue Nov 11 16:40:10 2025 -0700 Initial commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..899a7c7 --- /dev/null +++ b/.air.toml @@ -0,0 +1,44 @@ +root = "." +testdata_dir = "backend/testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -tags 'fts5 heic' -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "backend", "frontend"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..abb8584 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Git +.git +.gitignore + +# Data files +*.db +*.db-shm +*.db-wal +data/ + +# Node modules +frontend/node_modules +node_modules + +# Build artifacts +sbv +frontend/dist + +# Development files +.air.toml +tmp/ +*.log + +# IDE +.vscode +.idea + +# Documentation +README.md +*.md + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Test files +backend/testdata +*_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0ac3c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Backend +tmp/ +*.db +messages.db +build-errors.log +sbv +sbv-server +data/ + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.env.local +frontend/.env + +# General +*.log +*.tmp +.DS_Store +._.DS_Store +.vscode/ +.idea/ diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..d03257d --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,73 @@ +## Development + +- Go 1.25 or higher +- Node.js 22 or higher +- Air (for Go hot reload): `go install github.com/air-verse/air@latest` +- SQLite built with FTS5 support (for full-text search) +- libheif, for conversion of images + - If not installed, HEIC images will display as placeholder images +- ffmpeg, for conversion of videos + - If not installed, 3gp videos won't play + +Build with `go build -tags "fts5 heic"` + +## Tests + +Run all tests: +```bash +go test -tags "fts5 heic" +``` + +## Backend Setup + +1. Install Go dependencies: + ```bash + go mod download + ``` + +2. Run the backend with hot reload: + ```bash + air + ``` + + Or without hot reload: + ```bash + go run -tags "fts5 heic" . + ``` + +The backend will start on `http://localhost:8081` + +## Frontend Setup + +1. Navigate to the frontend directory: + ```bash + cd frontend + ``` + +2. Install npm dependencies: + ```bash + npm install + ``` + +3. Run the development server: + ```bash + npm run dev + ``` + +The frontend will start on `http://localhost:5173` + +## Backend Hot Reload + +The backend uses [Air](https://github.com/air-verse/air) for hot reload. When you save a `.go` file, Air will automatically rebuild and restart the server. + +## Frontend Hot Reload + +The frontend uses Vite's built-in hot module replacement (HMR). Changes to React components will be reflected instantly without a full page reload. + +# Usage + +1. **Start both servers** - Run the backend and frontend development servers +2. **Open the app** - Navigate to `http://localhost:5173` in your browser +3. **Upload backup** - Click "Upload Backup" and select your XML file +4. **Browse messages** - Click on conversations to view message threads +5. **Filter by date** - Use the date pickers to filter messages by date range diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d3e5618 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# Stage 1: Build frontend +FROM node:22-alpine AS frontend-builder + +WORKDIR /app/frontend + +# Copy frontend package files +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy frontend source +COPY frontend/ ./ + +# Build frontend +RUN npm run build + +# Stage 2: Build backend +FROM golang:bookworm AS backend-builder + +WORKDIR /app + +# Install build dependencies for libheif and SQLite FTS5 +# Using bookworm-backports to get a newer version of libheif +RUN echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list && \ + apt-get update && apt-get install -y \ + build-essential \ + && apt-get install -y -t bookworm-backports libheif-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy backend source +COPY *.go ./ + +# Build with FTS5 support +RUN go build -tags "fts5 heic" -o sbv . + +# Stage 3: Final runtime image +FROM debian:bookworm-slim + +WORKDIR /app + +# Install runtime dependencies +# Using bookworm-backports to get matching runtime library +RUN echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list && \ + apt-get update && apt-get install -y \ + ca-certificates \ + wget \ + ffmpeg \ + && apt-get install -y -t bookworm-backports libheif1 \ + && rm -rf /var/lib/apt/lists/* + +# Copy backend binary +COPY --from=backend-builder /app/sbv . + +# Copy frontend build +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create data directory for database +RUN mkdir -p /data + +# Set environment variables +ENV PORT=8081 +ENV DB_PATH_PREFIX=/data + +# Expose port +EXPOSE 8081 + +# Run the application +CMD ["./sbv"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..baf64af --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# SMS Backup Viewer (SBV) + +A modern web application for viewing SMS and MMS message backups. Import your messages from "SMS Backup & Restore" XML files and browse them in a texting app-like interface. + +## Demo + + +## Quick Start +docker: +```bash +docker run -d \ + -p 8081:8081 \ + -v $(pwd)/data:/data \ + -e DB_PATH_PREFIX=/data \ + lowcarbdev/sbv +``` + +docker-compose: +``` +services: + sbv: + image: lowcarbdev/sbv + ports: + - "8081:8081" + volumes: + # Mount data directory for persistent database storage + - ./data:/data + environment: + - PORT=8081 + - DB_PATH_PREFIX=/data + restart: unless-stopped +``` + +## Features + +- **Multi-user** - Create a username/password to log in +- **Import SMS Backup & Restore XML** - Upload XML files from the web interface. +- **Tested with large backups** - Works with multi-GB backups +- **SMS, MMS, and call logs support** - Read all types of call and message records. +- **Inline image and video** - View images or watch videos as you browse. Even works with Apple HEIC and 3gp videos. +- **Fast conversation filtering** - Skip to the right conversation. +- **Full-text search** - Find what you want fast. +- **Activity view** - See it as it happened. +- **vCard preview** - Preview the contents of contact cards (vCards) + +## Tech Stack + +- **Backend**: Go with SQLite database +- **Frontend**: React with Vite and Bootstrap CSS +- **Database**: SQLite (stores messages, including media as BLOBs) + +## Data Persistence + +The Docker setup uses a bind mount to persist the database: +- Host path: `./data/sbv*.db` +- Container path: `/data/sbv*.db` + +This ensures your data survives container restarts and updates. + +## License + +MIT + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/backend/testdata/sample_backup.xml b/backend/testdata/sample_backup.xml new file mode 100644 index 0000000..959d0f6 --- /dev/null +++ b/backend/testdata/sample_backup.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..b419480 --- /dev/null +++ b/build.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Build script for SMS/MMS Backup Viewer +# Builds with FTS5 (Full-Text Search) support enabled +# Optional: Build with HEIC support by setting HEIC=1 or passing 'heic' as an argument +# Usage: ./build.sh heic +# Or: HEIC=1 ./build.sh + +set -e + +# Check if HEIC support should be enabled +BUILD_TAGS="fts5" +if [ "$1" = "heic" ] || [ "$HEIC" = "1" ]; then + BUILD_TAGS="fts5 heic" + echo "Building SMS/MMS Backup Viewer with FTS5 and HEIC support..." + echo "Note: This requires libheif library to be installed" +else + echo "Building SMS/MMS Backup Viewer with FTS5 support..." + echo "Note: HEIC images will use placeholders. Build with HEIC=1 ./build.sh or ./build.sh heic to enable HEIC conversion" +fi + +go build -tags "$BUILD_TAGS" -o sbv . + +echo "Build complete! Binary: ./sbv" diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..4571c5a --- /dev/null +++ b/compose.yaml @@ -0,0 +1,19 @@ +services: + sbv: + build: . + container_name: sbv + ports: + - "8081:8081" + volumes: + # Mount data directory for persistent database storage + - ./data:/data + environment: + - PORT=8081 + - DB_PATH_PREFIX=/data + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# 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? diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dc8dfb4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + + + SMS Backup Viewer + + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5b7cd45 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3558 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.13.2", + "bootstrap": "^5.3.8", + "date-fns": "^4.1.0", + "react": "^19.1.1", + "react-bootstrap": "^2.10.10", + "react-datepicker": "^8.8.0", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.5" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "vite": "^7.1.7" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.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.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "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.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.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.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.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/@react-aria/ssr": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", + "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/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/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/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": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.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.43", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.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-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/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/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.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "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/baseline-browser-mapping": { + "version": "2.8.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", + "integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "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/browserslist": { + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" + }, + "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/caniuse-lite": { + "version": "1.0.30001753", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", + "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", + "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/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "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/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/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "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/electron-to-chromium": { + "version": "1.5.245", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.245.tgz", + "integrity": "sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==", + "dev": true, + "license": "ISC" + }, + "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.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "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.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "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==", + "dev": true, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/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.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "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.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/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-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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/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/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/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "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-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/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/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/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.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/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/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/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/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/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-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/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/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/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": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/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/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "license": "MIT", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.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/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz", + "integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-datepicker": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.8.0.tgz", + "integrity": "sha512-rIJLhww1B5cQY7GYEfSEXvldlGp+GIVU5oE7lxqeK4fmdv5F9bVndplDmblMCvfSMazXmeJ6OHBvRs/PkEhwUQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.15", + "clsx": "^2.1.1", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "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-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", + "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz", + "integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.5" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "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/rollup": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "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.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "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/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/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/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/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "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/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "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/vite": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.0.tgz", + "integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.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/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/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..39242b4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.2", + "bootstrap": "^5.3.8", + "date-fns": "^4.1.0", + "react": "^19.1.1", + "react-bootstrap": "^2.10.10", + "react-datepicker": "^8.8.0", + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.5" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "vite": "^7.1.7" + } +} diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..a4b4b2f Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-96x96.png b/frontend/public/favicon-96x96.png new file mode 100644 index 0000000..611204b Binary files /dev/null and b/frontend/public/favicon-96x96.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..33a584a Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..8dd0f78 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..ccf313a --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "MyWebSite", + "short_name": "MySite", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file 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/public/web-app-manifest-192x192.png b/frontend/public/web-app-manifest-192x192.png new file mode 100644 index 0000000..bb9bc56 Binary files /dev/null and b/frontend/public/web-app-manifest-192x192.png differ diff --git a/frontend/public/web-app-manifest-512x512.png b/frontend/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..2065310 Binary files /dev/null and b/frontend/public/web-app-manifest-512x512.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..8a880de --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + width: 100%; + height: 100vh; + margin: 0; + padding: 0; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..622bab3 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,338 @@ +import { useState, useEffect } from 'react' +import { useNavigate, useLocation, Routes, Route } from 'react-router-dom' +import { Dropdown } from 'react-bootstrap' +import axios from 'axios' +import { useAuth } from './contexts/AuthContext' +import ConversationList from './components/ConversationList' +import MessageThread from './components/MessageThread' +import Activity from './components/Activity' +import DateFilter from './components/DateFilter' +import Upload from './components/Upload' +import Search from './components/Search' +import ChangePasswordModal from './components/ChangePasswordModal' +import './App.css' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +function App() { + const navigate = useNavigate() + const location = useLocation() + const { user, logout } = useAuth() + const [conversations, setConversations] = useState([]) + const [conversationsLoading, setConversationsLoading] = useState(false) + const [selectedConversation, setSelectedConversation] = useState(null) + const [startDate, setStartDate] = useState(null) + const [endDate, setEndDate] = useState(null) + const [dateRange, setDateRange] = useState({ min: null, max: null }) + const [showUpload, setShowUpload] = useState(false) + const [showPasswordModal, setShowPasswordModal] = useState(false) + const [searchFilter, setSearchFilter] = useState('') + + // Search state (persisted across tab switches) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) + const [searchExecuted, setSearchExecuted] = useState(false) + const [searchScrollPosition, setSearchScrollPosition] = useState(0) + + // Derive activeView from URL + const activeView = location.pathname.startsWith('/activity') + ? 'activity' + : location.pathname.startsWith('/search') + ? 'search' + : 'conversations' + + useEffect(() => { + fetchDateRange() + fetchConversations() + }, []) + + useEffect(() => { + fetchConversations() + }, [startDate, endDate]) + + // Sync selected conversation from URL + useEffect(() => { + const match = location.pathname.match(/^\/conversation\/(.+)$/) + if (match) { + const address = decodeURIComponent(match[1]) + // Find conversation by address + const conversation = conversations.find(c => c.address === address) + if (conversation) { + setSelectedConversation(conversation) + } else if (conversations.length > 0) { + // If conversation not found in list, create a minimal conversation object + setSelectedConversation({ address, contact_name: address, type: 'message' }) + } + } else if (location.pathname === '/' || location.pathname === '/conversations') { + setSelectedConversation(null) + } + }, [location.pathname, conversations]) + + const fetchDateRange = async () => { + try { + const response = await axios.get(`${API_BASE}/daterange`) + setDateRange({ + min: new Date(response.data.min_date), + max: new Date(response.data.max_date) + }) + } catch (error) { + console.error('Error fetching date range:', error) + } + } + + const fetchConversations = async () => { + setConversationsLoading(true) + try { + const params = {} + if (startDate) params.start = startDate.toISOString() + if (endDate) params.end = endDate.toISOString() + + const response = await axios.get(`${API_BASE}/conversations`, { params }) + setConversations(response.data || []) + } catch (error) { + console.error('Error fetching conversations:', error) + } finally { + setConversationsLoading(false) + } + } + + const handleUploadSuccess = () => { + setShowUpload(false) + fetchDateRange() + fetchConversations() + } + + const handleSelectConversation = (conversation) => { + if (conversation) { + navigate(`/conversation/${encodeURIComponent(conversation.address)}`) + } + } + + const handleViewChange = (view) => { + if (view === 'activity') { + navigate('/activity') + } else if (view === 'search') { + navigate('/search') + } else { + navigate('/') + } + } + + // Filter conversations based on search text + const filteredConversations = conversations.filter(conv => { + if (!searchFilter) return true + + const searchLower = searchFilter.toLowerCase() + const nameMatch = conv.contact_name && conv.contact_name.toLowerCase().includes(searchLower) + const addressMatch = conv.address && conv.address.toLowerCase().includes(searchLower) + + return nameMatch || addressMatch + }) + + return ( +
+ {/* Header */} +
+
+
+ + + +
+

SMS Backup Viewer

+

View and browse your message history

+
+
+
+
+
Logged in as
+
{user?.username}
+
+ + + + + + + + + setShowPasswordModal(true)}> + + + + Change Password + + + + + + + Logout + + + +
+
+
+ + {/* View Switcher */} +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+
+ + {/* Date Filter */} +
+ +
+ + {/* Main Content */} +
+ {activeView === 'conversations' ? ( + <> + {/* Conversation List */} +
+
+

+ + + + Conversations +

+
+ + + + setSearchFilter(e.target.value)} + /> +
+
+
+ +
+
+ + {/* Message Thread */} +
+ +
+ + ) : activeView === 'search' ? ( + /* Search View */ +
+ +
+ ) : ( + /* Activity View */ +
+ +
+ )} +
+ + {/* Upload Modal */} + {showUpload && ( + setShowUpload(false)} + onSuccess={handleUploadSuccess} + /> + )} + + {/* Change Password Modal */} + {showPasswordModal && ( + setShowPasswordModal(false)} + onSuccess={() => { + // Password changed successfully + console.log('Password changed successfully') + }} + /> + )} +
+ ) +} + +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/Activity.jsx b/frontend/src/components/Activity.jsx new file mode 100644 index 0000000..c2a9bc7 --- /dev/null +++ b/frontend/src/components/Activity.jsx @@ -0,0 +1,437 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import axios from 'axios' +import LazyMedia from './LazyMedia' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' +const PAGE_SIZE = 50 + +function Activity({ startDate, endDate }) { + const [activities, setActivities] = useState([]) + const [loading, setLoading] = useState(true) + const [loadingMore, setLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(true) + const [offset, setOffset] = useState(0) + const observerTarget = useRef(null) + const scrollContainerRef = useRef(null) + + // Reset when date range changes + useEffect(() => { + setActivities([]) + setOffset(0) + setHasMore(true) + fetchActivity(0, false) + }, [startDate, endDate]) + + const fetchActivity = async (currentOffset, append = false) => { + if (append) { + setLoadingMore(true) + } else { + setLoading(true) + } + + try { + const params = { + limit: PAGE_SIZE, + offset: currentOffset + } + if (startDate) params.start = startDate.toISOString() + if (endDate) params.end = endDate.toISOString() + + const response = await axios.get(`${API_BASE}/activity`, { params }) + const newActivities = response.data || [] + + // If we got fewer items than the page size, we've reached the end + if (newActivities.length < PAGE_SIZE) { + setHasMore(false) + } + + if (append) { + setActivities(prev => [...prev, ...newActivities]) + } else { + setActivities(newActivities) + } + } catch (error) { + console.error('Error fetching activity:', error) + } finally { + setLoading(false) + setLoadingMore(false) + } + } + + const loadMore = useCallback(() => { + console.log('loadMore called:', { loadingMore, hasMore, offset }) + if (!loadingMore && hasMore) { + const newOffset = offset + PAGE_SIZE + setOffset(newOffset) + fetchActivity(newOffset, true) + } + }, [offset, loadingMore, hasMore]) + + // Set up intersection observer for infinite scroll + useEffect(() => { + // Make sure both refs are available + if (!scrollContainerRef.current || !observerTarget.current) { + console.log('Refs not ready:', { scroll: !!scrollContainerRef.current, target: !!observerTarget.current }) + return + } + + console.log('Setting up IntersectionObserver', { hasMore, loadingMore, activitiesCount: activities.length }) + + const observer = new IntersectionObserver( + (entries) => { + console.log('Observer callback fired', { + isIntersecting: entries[0].isIntersecting, + hasMore, + loadingMore + }) + if (entries[0].isIntersecting && hasMore && !loadingMore) { + console.log('Intersection detected, loading more...') + loadMore() + } + }, + { + root: scrollContainerRef.current, + rootMargin: '100px', + threshold: 0.1 + } + ) + + observer.observe(observerTarget.current) + + return () => { + observer.disconnect() + } + }, [loadMore, hasMore, loadingMore, activities]) + + const formatCallType = (type) => { + switch (type) { + case 1: return { label: 'Incoming call', icon: '📞', color: 'success' } + case 2: return { label: 'Outgoing call', icon: '📱', color: 'primary' } + case 3: return { label: 'Missed call', icon: '📵', color: 'danger' } + case 4: return { label: 'Voicemail', icon: '🎙️', color: 'info' } + case 5: return { label: 'Rejected call', icon: '🚫', color: 'warning' } + case 6: return { label: 'Refused call', icon: '❌', color: 'danger' } + default: return { label: 'Call', icon: '📞', color: 'secondary' } + } + } + + const formatDuration = (seconds) => { + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${minutes}m ${secs}s` + } + + const formatPhoneNumber = (phoneNumber) => { + if (!phoneNumber) return '' + + // Handle comma-separated numbers (group conversations) + if (phoneNumber.includes(',')) { + const numbers = phoneNumber.split(',').map(n => n.trim()) + return numbers.map(n => formatSinglePhoneNumber(n)).join(', ') + } + + return formatSinglePhoneNumber(phoneNumber) + } + + const formatSinglePhoneNumber = (phoneNumber) => { + if (!phoneNumber) return '' + + // Remove any non-numeric characters except leading + + let cleaned = phoneNumber.replace(/[^\d+]/g, '') + + // Handle +1 prefix (US numbers) + if (cleaned.startsWith('+1') && cleaned.length === 12) { + // Format as +1 (XXX) XXX-XXXX + return `+1 (${cleaned.slice(2, 5)}) ${cleaned.slice(5, 8)}-${cleaned.slice(8)}` + } + + // Handle numbers with + country code + if (cleaned.startsWith('+')) { + return cleaned // Return international numbers as-is + } + + // Handle 11-digit numbers starting with 1 (US numbers without +) + if (cleaned.length === 11 && cleaned.startsWith('1')) { + return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` + } + + // Handle 10-digit US numbers + if (cleaned.length === 10) { + return `+1 (${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}` + } + + // Return as-is if format doesn't match + return phoneNumber + } + + const formatDate = (dateStr) => { + const date = new Date(dateStr) + const now = new Date() + const diffMs = now - date + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + + const timeStr = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + + if (diffDays === 0) return `Today at ${timeStr}` + if (diffDays === 1) return `Yesterday at ${timeStr}` + if (diffDays < 7) return date.toLocaleDateString('en-US', { + weekday: 'long', + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + } + + const getMessageTypeLabel = (type) => { + switch (type) { + case 1: return { label: 'Received', color: 'primary' } + case 2: return { label: 'Sent', color: 'success' } + case 3: return { label: 'Draft', color: 'secondary' } + case 4: return { label: 'Outbox', color: 'warning' } + case 5: return { label: 'Failed', color: 'danger' } + case 6: return { label: 'Queued', color: 'info' } + default: return { label: 'Message', color: 'secondary' } + } + } + + const shouldDisplaySubject = (subject) => { + if (!subject) return false + // Filter out protocol buffer/RCS subjects + if (subject.startsWith('proto:')) return false + return true + } + + // Get sender display name for a message in group conversations + const getSenderDisplayName = (message) => { + // For received messages, use the sender field if available + let senderPhone = message.sender + + // If sender is empty, try to extract from addresses array + if (!senderPhone && message.addresses && message.addresses.length > 0) { + // Use the first address as the sender + senderPhone = message.addresses[0] + } + + // If sender contains comma-separated numbers (shouldn't happen, but handle it), + // extract only the first one + if (senderPhone && senderPhone.includes(',')) { + senderPhone = senderPhone.split(',')[0].trim() + } + + if (!senderPhone) return 'Unknown' + + // Format as a single phone number (not as a group) + return formatSinglePhoneNumber(senderPhone) + } + + if (loading) { + return ( +
+
+
+ Loading... +
+

Loading activity...

+
+
+ ) + } + + if (activities.length === 0) { + return ( +
+
+ + + +

No activity found

+
+
+ ) + } + + return ( +
+
+

+ + + + Activity Timeline + {activities.length} items +

+
+ +
+
+ {activities.map((activity, index) => { + if (activity.type === 'message' && activity.message) { + const msg = activity.message + const msgType = getMessageTypeLabel(msg.type) + + // For MMS with multiple recipients, use the addresses array; otherwise use the single address + let displayAddress + if (msg.addresses && msg.addresses.length > 0) { + // Format each address and join with commas + displayAddress = msg.addresses.map(addr => formatPhoneNumber(addr)).join(', ') + } else { + // Fall back to the single address field + displayAddress = formatPhoneNumber(activity.address) + } + + const displayName = activity.contact_name || displayAddress + + // Check if this is a group conversation + const isGroupConversation = msg.addresses && msg.addresses.length > 1 + const isSent = msg.type === 2 + const showSenderLabel = isGroupConversation && !isSent + + // Debug logging for ALL messages to understand what we're receiving + console.log('Message received:', { + id: msg.id, + addresses: msg.addresses, + addressesType: typeof msg.addresses, + addressesLength: msg.addresses?.length, + sender: msg.sender, + address: msg.address, + type: msg.type, + isSent, + isGroupConversation, + showSenderLabel, + body: msg.body?.substring(0, 30) + }) + + return ( +
+
+
+
+ + + +
+
+ {displayName} +
+ {displayAddress} +
+
+
+ {msgType.label} +
+ {formatDate(activity.date)} +
+
+ + {/* Sender label for received messages in group conversations */} + {showSenderLabel && ( +
+ + From: {getSenderDisplayName(msg)} + +
+ )} + + {shouldDisplaySubject(msg.subject) && ( +
+ Subject: {msg.subject} +
+ )} + + {msg.body && ( +

{msg.body}

+ )} + + {msg.media_type && ( + + )} +
+
+ ) + } else if (activity.type === 'call' && activity.call) { + const call = activity.call + const callType = formatCallType(call.type) + const formattedAddress = formatPhoneNumber(activity.address) + const displayName = activity.contact_name || formattedAddress + + return ( +
+
+
+
+
{callType.icon}
+
+
+ {displayName} +
+ {formattedAddress} +
+
+
+ {callType.label} +
+ {formatDate(activity.date)} +
+
+ {call.duration > 0 && ( +
+ + + + + Duration: {formatDuration(call.duration)} + +
+ )} +
+
+ ) + } + return null + })} + + {/* Infinite scroll trigger */} +
+ + {/* Loading more indicator */} + {loadingMore && ( +
+
+ Loading more... +
+

Loading more activities...

+
+ )} + + {/* End of results indicator */} + {!hasMore && activities.length > 0 && ( +
+ No more activities to load +
+ )} +
+
+
+ ) +} + +export default Activity diff --git a/frontend/src/components/ChangePasswordModal.jsx b/frontend/src/components/ChangePasswordModal.jsx new file mode 100644 index 0000000..26fff72 --- /dev/null +++ b/frontend/src/components/ChangePasswordModal.jsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { Modal, Button, Form, Alert } from 'react-bootstrap' +import { useAuth } from '../contexts/AuthContext' + +function ChangePasswordModal({ onClose, onSuccess }) { + const { changePassword } = useAuth() + const [oldPassword, setOldPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const handleSubmit = async (e) => { + e.preventDefault() + setError(null) + + // Validate fields + if (!oldPassword || !newPassword || !confirmPassword) { + setError('All fields are required') + return + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match') + return + } + + if (newPassword.length < 6) { + setError('New password must be at least 6 characters') + return + } + + setLoading(true) + + try { + const result = await changePassword(oldPassword, newPassword, confirmPassword) + + if (result.success) { + if (onSuccess) { + onSuccess() + } + onClose() + } else { + setError(result.error || 'Failed to change password') + } + } catch (err) { + setError('Failed to change password. Please try again.') + } finally { + setLoading(false) + } + } + + return ( + + + Change Password + + + {error && ( + setError(null)}> + {error} + + )} +
+ + Current Password + setOldPassword(e.target.value)} + disabled={loading} + autoFocus + required + /> + + + New Password + setNewPassword(e.target.value)} + disabled={loading} + required + minLength={6} + /> + + Must be at least 6 characters + + + + Confirm New Password + setConfirmPassword(e.target.value)} + disabled={loading} + required + minLength={6} + /> + +
+
+ + + + +
+ ) +} + +export default ChangePasswordModal diff --git a/frontend/src/components/ConversationList.jsx b/frontend/src/components/ConversationList.jsx new file mode 100644 index 0000000..06bf4c3 --- /dev/null +++ b/frontend/src/components/ConversationList.jsx @@ -0,0 +1,170 @@ +import { formatDistanceToNow } from 'date-fns' + +function ConversationList({ conversations, selectedConversation, onSelectConversation, loading }) { + const formatDate = (date) => { + try { + return formatDistanceToNow(new Date(date), { addSuffix: true }) + } catch { + return '' + } + } + + const truncateMessage = (message, maxLength = 50) => { + if (!message) return '' + if (message.length <= maxLength) return message + return message.substring(0, maxLength).trim() + '...' + } + + const formatPhoneNumber = (number) => { + if (!number) return 'Unknown' + + // Handle comma-separated numbers (group conversations) + if (number.includes(',')) { + const numbers = number.split(',').map(n => n.trim()) + return numbers.map(n => formatSinglePhoneNumber(n)).join(', ') + } + + return formatSinglePhoneNumber(number) + } + + const formatSinglePhoneNumber = (number) => { + if (!number) return 'Unknown' + + // Remove all non-digit characters + const cleaned = number.replace(/\D/g, '') + + // Handle 11-digit numbers (e.g., +1 country code) + if (cleaned.length === 11 && cleaned.startsWith('1')) { + return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` + } + + // Handle 10-digit numbers (US format) + if (cleaned.length === 10) { + return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}` + } + + // Handle other formats - try to format with spaces + if (cleaned.length > 10) { + // International format: +XX XXX XXX XXXX + return `+${cleaned.slice(0, cleaned.length - 10)} ${cleaned.slice(cleaned.length - 10, cleaned.length - 7)} ${cleaned.slice(cleaned.length - 7, cleaned.length - 4)} ${cleaned.slice(cleaned.length - 4)}` + } + + // Return original if we can't format it nicely + return number + } + + const shouldDisplaySubject = (subject) => { + if (!subject) return false + // Filter out protocol buffer/RCS subjects + if (subject.startsWith('proto:')) return false + return true + } + + const getDisplayName = (conv) => { + // If we have a valid subject, use it when contact_name is empty, "(Unknown)", or looks like an 8-digit number + if (conv.subject && shouldDisplaySubject(conv.subject)) { + if (!conv.contact_name || conv.contact_name === '(Unknown)' || /^\d{8}$/.test(conv.contact_name)) { + return conv.subject + } + } + // If contact_name is empty, null, or "(Unknown)", use formatted phone number + if (!conv.contact_name || conv.contact_name === '(Unknown)') { + return formatPhoneNumber(conv.address) + } + return conv.contact_name + } + + const getConversationIcon = (type) => { + if (type === 'call') { + return ( + + + + ) + } + return ( + + + + ) + } + + if (loading) { + return ( +
+
+
+ Loading... +
+

Loading conversations...

+
+
+ ) + } + + if (conversations.length === 0) { + return ( +
+
+ + + +

No conversations found

+

Upload a backup file to get started

+
+
+ ) + } + + return ( +
+ {conversations.map((conv, index) => { + const isSelected = selectedConversation && + selectedConversation.address === conv.address && + selectedConversation.type === conv.type + + return ( +
onSelectConversation(conv)} + className={`list-group-item list-group-item-action ${ + isSelected ? 'active' : '' + }`} + style={{cursor: 'pointer'}} + > +
+
+ {getConversationIcon(conv.type)} +
+
+
+
+ {getDisplayName(conv)} +
+ + {formatDate(conv.last_date)} + +
+

+ {truncateMessage(conv.last_message, 50)} +

+
+ + {conv.message_count} {conv.type === 'call' ? 'call' : 'message'}{conv.message_count !== 1 ? 's' : ''} + +
+
+
+
+ ) + })} +
+ ) +} + +export default ConversationList diff --git a/frontend/src/components/DateFilter.jsx b/frontend/src/components/DateFilter.jsx new file mode 100644 index 0000000..6483ab7 --- /dev/null +++ b/frontend/src/components/DateFilter.jsx @@ -0,0 +1,67 @@ +import DatePicker from 'react-datepicker' +import 'react-datepicker/dist/react-datepicker.css' + +function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, onEndDateChange }) { + const clearDates = () => { + onStartDateChange(null) + onEndDateChange(null) + } + + return ( +
+
+
+ + + + + +
+ +
+ + + + + +
+ + {(startDate || endDate) && ( + + )} +
+
+ ) +} + +export default DateFilter diff --git a/frontend/src/components/LazyMedia.jsx b/frontend/src/components/LazyMedia.jsx new file mode 100644 index 0000000..21b89d7 --- /dev/null +++ b/frontend/src/components/LazyMedia.jsx @@ -0,0 +1,332 @@ +import { useState, useEffect, useRef } from 'react' +import axios from 'axios' +import VCardPreview from './VCardPreview' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" }) { + const [src, setSrc] = useState(null) + const [vcfData, setVcfData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(false) + const [showModal, setShowModal] = useState(false) + const imgRef = useRef(null) + const videoRef = useRef(null) + const observerRef = useRef(null) + const hasLoadedRef = useRef(false) + + useEffect(() => { + // Reset loaded state when messageId changes + hasLoadedRef.current = false + + // Set up Intersection Observer for lazy loading + observerRef.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasLoadedRef.current) { + hasLoadedRef.current = true + loadMedia() + } + }) + }, + { + // Only load images below viewport (not above) to prevent scroll jump + // rootMargin: top right bottom left + rootMargin: '50px 0px 200px 0px' + } + ) + + if (imgRef.current) { + observerRef.current.observe(imgRef.current) + } + + return () => { + if (observerRef.current) { + observerRef.current.disconnect() + } + } + }, [messageId]) + + const loadMedia = async () => { + setLoading(true) + try { + // Check if this is a VCF file - fetch as text instead of blob + const isVCard = mediaType === 'text/x-vcard' || + mediaType === 'text/vcard' || + mediaType === 'text/directory' + + if (isVCard) { + // Fetch VCF as text + const response = await axios.get(`${API_BASE}/media`, { + params: { id: messageId }, + responseType: 'text' + }) + setVcfData(response.data) + } else { + // Fetch other media as blob + const response = await axios.get(`${API_BASE}/media`, { + params: { id: messageId }, + responseType: 'blob' + }) + + const blob = response.data + const objectUrl = URL.createObjectURL(blob) + setSrc(objectUrl) + } + + // Stop observing once loaded - we don't need to track this element anymore + if (observerRef.current && imgRef.current) { + observerRef.current.unobserve(imgRef.current) + } + } catch (err) { + console.error('Failed to load media:', err) + setError(true) + } finally { + setLoading(false) + } + } + + // Cleanup object URL when component unmounts + useEffect(() => { + return () => { + if (src) { + URL.revokeObjectURL(src) + } + } + }, [src]) + + // Handle ESC key to close modal + useEffect(() => { + const handleEscape = (e) => { + if (e.key === 'Escape' && showModal) { + setShowModal(false) + } + } + + if (showModal) { + document.addEventListener('keydown', handleEscape) + // Prevent body scroll when modal is open + document.body.style.overflow = 'hidden' + } + + return () => { + document.removeEventListener('keydown', handleEscape) + document.body.style.overflow = 'unset' + } + }, [showModal]) + + // Pause original video when modal opens + useEffect(() => { + if (showModal && videoRef.current) { + videoRef.current.pause() + } + }, [showModal]) + + if (!mediaType) { + return null + } + + const isImage = mediaType.startsWith('image/') + const isVideo = mediaType.startsWith('video/') + const isVCard = mediaType === 'text/x-vcard' || + mediaType === 'text/vcard' || + mediaType === 'text/directory' + + return ( + <> +
+ {/* Placeholder shown before loading or while loading */} + {!src && !vcfData && !error && ( +
+
+ {loading ? ( + <> +
+ Loading... +
+
Loading {isImage ? 'image' : isVideo ? 'video' : isVCard ? 'contact' : 'media'}...
+ + ) : ( +
+ {isImage && ( + + + + )} + {isVideo && ( + + + + )} + {isVCard && ( + + + + )} + {!isImage && !isVideo && !isVCard && ( + + + + )} + + {isImage ? 'Image' : isVideo ? 'Video' : isVCard ? 'Contact' : 'Attachment'} + +
+ )} +
+
+ )} + + {error && ( +
+
+ + + + Failed to load media +
+
+ )} + + {(src || vcfData) && !loading && !error && ( + <> + {isImage && src && ( + {alt} setShowModal(true)} + style={{ + display: 'block', + maxWidth: '100%', + maxHeight: '400px', + objectFit: 'contain', + animation: 'fadeIn 0.3s ease-in', + cursor: 'pointer' + }} + /> + )} + {isVideo && src && ( +
+ + {/* Full-screen modal */} + {showModal && (isImage || isVideo) && src && ( +
setShowModal(false)} + > + {/* Close button */} + + + {/* Media content - stop propagation to prevent closing when clicking on media */} +
e.stopPropagation()} + className="d-flex align-items-center justify-content-center" + style={{ + maxWidth: '95vw', + maxHeight: '95vh' + }} + > + {isImage && ( + {alt} + )} + {isVideo && ( +
+
+ )} + + ) +} + +export default LazyMedia diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx new file mode 100644 index 0000000..fc7d782 --- /dev/null +++ b/frontend/src/components/Login.jsx @@ -0,0 +1,179 @@ +import { useState } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { useNavigate } from 'react-router-dom' + +function Login() { + const [isLogin, setIsLogin] = useState(true) + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const { login, register } = useAuth() + const navigate = useNavigate() + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + // Validation + if (!username.trim() || !password) { + setError('Username and password are required') + return + } + + if (!isLogin) { + if (username.trim().length < 3) { + setError('Username must be at least 3 characters') + return + } + if (password.length < 6) { + setError('Password must be at least 6 characters') + return + } + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + } + + setLoading(true) + + try { + const result = isLogin + ? await login(username.trim(), password) + : await register(username.trim(), password) + + if (result.success) { + navigate('/') + } else { + setError(result.error || 'Authentication failed') + } + } catch (err) { + setError('An unexpected error occurred') + } finally { + setLoading(false) + } + } + + const toggleMode = () => { + setIsLogin(!isLogin) + setError('') + setPassword('') + setConfirmPassword('') + } + + return ( +
+
+
+
+
+
+
+

+ + + + SMS Backup Viewer +

+

+ {isLogin ? 'Sign in to your account' : 'Create a new account'} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + required + autoComplete="username" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete={isLogin ? 'current-password' : 'new-password'} + disabled={loading} + /> +
+ + {!isLogin && ( +
+ + setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + disabled={loading} + /> +
+ )} + + + +
+ +
+
+
+
+
+
+
+
+ ) +} + +export default Login diff --git a/frontend/src/components/MessageThread.jsx b/frontend/src/components/MessageThread.jsx new file mode 100644 index 0000000..6e4e8ad --- /dev/null +++ b/frontend/src/components/MessageThread.jsx @@ -0,0 +1,542 @@ +import { useState, useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' +import axios from 'axios' +import { format } from 'date-fns' +import LazyMedia from './LazyMedia' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +function MessageThread({ conversation, startDate, endDate }) { + const location = useLocation() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [highlightedMessageId, setHighlightedMessageId] = useState(null) + const messageRefs = useRef({}) + + useEffect(() => { + if (conversation) { + fetchItems() + } else { + setItems([]) + } + }, [conversation, startDate, endDate]) + + // Scroll to specific message if messageId is in URL + useEffect(() => { + if (items.length > 0) { + const params = new URLSearchParams(location.search) + const messageId = params.get('messageId') + + if (messageId) { + setHighlightedMessageId(messageId) + if (messageRefs.current[messageId]) { + const element = messageRefs.current[messageId] + + // Function to wait for media in an element to load + const waitForMediaInElement = (elem) => { + const images = Array.from(elem.querySelectorAll('img')) + const videos = Array.from(elem.querySelectorAll('video')) + const media = [...images, ...videos] + + if (media.length === 0) { + return Promise.resolve() + } + + const mediaPromises = media.map(mediaElement => { + if (mediaElement.complete || mediaElement.readyState >= 2) { + return Promise.resolve() + } + return new Promise((resolve) => { + mediaElement.addEventListener('load', resolve, { once: true }) + mediaElement.addEventListener('loadeddata', resolve, { once: true }) + mediaElement.addEventListener('error', resolve, { once: true }) + setTimeout(resolve, 3000) + }) + }) + + return Promise.all(mediaPromises) + } + + // Function to perform the scroll + const scrollToElement = () => { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + } + + // Multi-stage scroll approach: + // 1. Initial scroll to get element near viewport (triggers lazy loading) + // 2. Wait for lazy-loaded media + // 3. Final scroll to correct position + setTimeout(() => { + // First scroll - instant to trigger lazy loading + element.scrollIntoView({ + behavior: 'instant', + block: 'center' + }) + + // Wait a bit for lazy loading to trigger + setTimeout(() => { + // Wait for media to load + waitForMediaInElement(element).then(() => { + // Final smooth scroll to correct position + scrollToElement() + + // Re-scroll after a delay to handle any late-loading media + setTimeout(scrollToElement, 500) + setTimeout(scrollToElement, 1500) + }) + }, 200) + }, 100) + } + } else { + setHighlightedMessageId(null) + } + } + }, [items, location.search]) + + // Automatically scroll to the last message when opening a conversation + useEffect(() => { + if (items.length > 0) { + const params = new URLSearchParams(location.search) + const messageId = params.get('messageId') + + // Only auto-scroll if there's no specific messageId in the URL + if (!messageId) { + // Find the last message (not a call) to scroll to + const lastItem = items[items.length - 1] + let lastMessageId = null + + // Handle ActivityItem format vs direct Message format + if (lastItem.type === 'message' && lastItem.message) { + lastMessageId = lastItem.message.id + } else if (lastItem.type === 'call') { + // If last item is a call, find the last message before it + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].type === 'message' && items[i].message) { + lastMessageId = items[i].message.id + break + } + } + } else if (lastItem.id) { + // Direct message format + lastMessageId = lastItem.id + } + + if (lastMessageId && messageRefs.current[lastMessageId]) { + const element = messageRefs.current[lastMessageId] + + // Function to wait for media in an element to load + const waitForMediaInElement = (elem) => { + const images = Array.from(elem.querySelectorAll('img')) + const videos = Array.from(elem.querySelectorAll('video')) + const media = [...images, ...videos] + + if (media.length === 0) { + return Promise.resolve() + } + + const mediaPromises = media.map(mediaElement => { + if (mediaElement.complete || mediaElement.readyState >= 2) { + return Promise.resolve() + } + return new Promise((resolve) => { + mediaElement.addEventListener('load', resolve, { once: true }) + mediaElement.addEventListener('loadeddata', resolve, { once: true }) + mediaElement.addEventListener('error', resolve, { once: true }) + setTimeout(resolve, 3000) + }) + }) + + return Promise.all(mediaPromises) + } + + // Function to perform the scroll + const scrollToElement = () => { + element.scrollIntoView({ + behavior: 'instant', + block: 'end' + }) + } + + // Scroll to last message after a short delay to ensure rendering is complete + setTimeout(() => { + // First scroll to trigger lazy loading if needed + scrollToElement() + + // Wait for media to load, then scroll again + setTimeout(() => { + waitForMediaInElement(element).then(() => { + scrollToElement() + // Re-scroll after a delay to handle any late-loading media + setTimeout(scrollToElement, 300) + }) + }, 100) + }, 100) + } + } + } + }, [items, location.search]) + + const fetchItems = async () => { + setLoading(true) + try { + const params = { + address: conversation.address, + type: conversation.type + } + if (startDate) params.start = startDate.toISOString() + if (endDate) params.end = endDate.toISOString() + + const response = await axios.get(`${API_BASE}/messages`, { params }) + setItems(response.data || []) + } catch (error) { + console.error('Error fetching items:', error) + } finally { + setLoading(false) + } + } + + const formatTime = (date) => { + return format(new Date(date), 'MMM d, yyyy h:mm a') + } + + const formatDuration = (seconds) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + const formatPhoneNumber = (number) => { + if (!number) return 'Unknown' + + // Handle comma-separated numbers (group conversations) + if (number.includes(',')) { + const numbers = number.split(',').map(n => n.trim()) + return numbers.map(n => formatSinglePhoneNumber(n)).join(', ') + } + + return formatSinglePhoneNumber(number) + } + + const formatSinglePhoneNumber = (number) => { + if (!number) return 'Unknown' + + // Remove all non-digit characters + const cleaned = number.replace(/\D/g, '') + + // Handle 11-digit numbers (e.g., +1 country code) + if (cleaned.length === 11 && cleaned.startsWith('1')) { + return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` + } + + // Handle 10-digit numbers (US format) + if (cleaned.length === 10) { + return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}` + } + + // Handle other formats - try to format with spaces + if (cleaned.length > 10) { + // International format: +XX XXX XXX XXXX + return `+${cleaned.slice(0, cleaned.length - 10)} ${cleaned.slice(cleaned.length - 10, cleaned.length - 7)} ${cleaned.slice(cleaned.length - 7, cleaned.length - 4)} ${cleaned.slice(cleaned.length - 4)}` + } + + // Return original if we can't format it nicely + return number + } + + const getDisplayName = (conv) => { + // If we have a valid subject, use it when contact_name is empty, "(Unknown)", or looks like an 8-digit number + if (conv.subject && shouldDisplaySubject(conv.subject)) { + if (!conv.contact_name || conv.contact_name === '(Unknown)' || /^\d{8}$/.test(conv.contact_name)) { + return conv.subject + } + } + // If contact_name is empty, null, or "(Unknown)", use formatted phone number + if (!conv.contact_name || conv.contact_name === '(Unknown)') { + return formatPhoneNumber(conv.address) + } + return conv.contact_name + } + + const shouldDisplaySubject = (subject) => { + if (!subject) return false + // Filter out protocol buffer/RCS subjects + if (subject.startsWith('proto:')) return false + return true + } + + const getCallTypeInfo = (type) => { + switch (type) { + case 1: return { label: 'Incoming', color: 'text-success', bgColor: 'bg-success', icon: '↓' } + case 2: return { label: 'Outgoing', color: 'text-primary', bgColor: 'bg-primary', icon: '↑' } + case 3: return { label: 'Missed', color: 'text-danger', bgColor: 'bg-danger', icon: '✕' } + case 4: return { label: 'Voicemail', color: 'text-info', bgColor: 'bg-info', icon: '⊙' } + case 5: return { label: 'Rejected', color: 'text-warning', bgColor: 'bg-warning', icon: '✕' } + case 6: return { label: 'Refused', color: 'text-secondary', bgColor: 'bg-secondary', icon: '✕' } + default: return { label: 'Call', color: 'text-secondary', bgColor: 'bg-secondary', icon: '○' } + } + } + + // Check if conversation is a group conversation + // Handle both ActivityItem format (items[0].message) and direct Message format (items[0]) + const isGroupConversation = items.length > 0 && (() => { + const firstItem = items[0] + // ActivityItem format: check message.addresses + if (firstItem.type === 'message' && firstItem.message) { + return firstItem.message.addresses && firstItem.message.addresses.length > 1 + } + // Direct Message format: check addresses directly + return firstItem.addresses && firstItem.addresses.length > 1 + })() + + // Get sender display name for a message + const getSenderDisplayName = (message) => { + // For received messages, use the sender field if available + let senderPhone = message.sender + + // If sender is empty, try to extract from addresses array + // (exclude any number that might be "me" - this is a received message so sender is someone else) + if (!senderPhone && message.addresses && message.addresses.length > 0) { + // For now, use the first address as the sender + // In the future, we could exclude the current user's number + senderPhone = message.addresses[0] + } + + // If sender contains comma-separated numbers (shouldn't happen, but handle it), + // extract only the first one + if (senderPhone && senderPhone.includes(',')) { + senderPhone = senderPhone.split(',')[0].trim() + } + + if (!senderPhone) return 'Unknown' + + // Format as a single phone number (not as a group) + return formatSinglePhoneNumber(senderPhone) + } + + if (!conversation) { + return ( +
+
+ + + +

Select a conversation

+

Choose a conversation from the list to view messages

+
+
+ ) + } + + if (loading) { + return ( +
+
+
+ Loading... +
+

Loading messages...

+
+
+ ) + } + + const isCallLog = conversation.type === 'call' + + return ( +
+ {/* Thread Header */} +
+
+
+ {isCallLog ? ( + + + + ) : ( + + + + )} +
+
+

+ {getDisplayName(conversation)} +

+ {/* Display phone numbers for conversations with addresses */} + {!isCallLog && items.length > 0 && (() => { + const firstItem = items[0] + // Get addresses from either ActivityItem.message or direct Message + const addresses = (firstItem.type === 'message' && firstItem.message) + ? firstItem.message.addresses + : firstItem.addresses + return addresses && addresses.length > 0 && ( +
+ {addresses.map((addr, idx) => ( + + {formatPhoneNumber(addr)} + {idx < addresses.length - 1 ? ', ' : ''} + + ))} +
+ ) + })()} +
+ + {items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''} + +
+
+
+
+ + {/* Content */} +
+ {isCallLog ? ( + // Call Log View +
+ {items.map((call) => { + const typeInfo = getCallTypeInfo(call.type) + return ( +
+
+
+
+
+ + {typeInfo.icon} + +
+
+
+ {typeInfo.label} Call +
+
+ + + + {formatTime(call.date)} +
+
+
+
+
+ {formatDuration(call.duration)} +
+
+
+
+
+ ) + })} +
+ ) : ( + // Unified Message and Call View +
+ {items.map((item) => { + // Check if this is an ActivityItem (has type field) or a direct Message + const isActivityItem = item.type === 'message' || item.type === 'call' + const isCall = isActivityItem && item.type === 'call' + const message = isActivityItem ? item.message : item + const call = isActivityItem ? item.call : null + + if (isCall && call) { + // Compact call representation - inline with messages + const typeInfo = getCallTypeInfo(call.type) + return ( +
+
+ {typeInfo.icon} + {typeInfo.label} call + · + {formatTime(call.date)} + {call.duration > 0 && ( + <> + · + {formatDuration(call.duration)} + + )} +
+
+ ) + } + + // Message rendering + if (!message) return null + + const isSent = message.type === 2 + const isHighlighted = highlightedMessageId === String(message.id) + const showSenderLabel = isGroupConversation && !isSent + + return ( +
+
+ {/* Sender label for received messages in group conversations */} + {showSenderLabel && ( +
+ {getSenderDisplayName(message)} +
+ )} +
(messageRefs.current[message.id] = el)} + className={`card shadow-sm ${ + isSent + ? 'bg-primary text-white' + : 'bg-white' + } ${ + isHighlighted + ? 'border-warning border-3' + : 'border-2' + }`} + style={{ + padding: '0.5em', + position: 'relative' + }} + > +
+ {message.body && ( +
+ {message.body} +
+ )} + {message.media_type && ( + + )} +
+ + + + {formatTime(message.date)} +
+
+
+
+
+ ) + })} +
+ )} +
+
+ ) +} + +export default MessageThread diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..4fbe9b6 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,27 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +function ProtectedRoute({ children }) { + const { isAuthenticated, loading } = useAuth() + + if (loading) { + return ( +
+
+
+ Loading... +
+

Loading...

+
+
+ ) + } + + if (!isAuthenticated) { + return + } + + return children +} + +export default ProtectedRoute diff --git a/frontend/src/components/Search.jsx b/frontend/src/components/Search.jsx new file mode 100644 index 0000000..91e7153 --- /dev/null +++ b/frontend/src/components/Search.jsx @@ -0,0 +1,206 @@ +import { useNavigate } from 'react-router-dom' +import { useEffect, useRef } from 'react' +import axios from 'axios' +import { format } from 'date-fns' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +function Search({ searchQuery, setSearchQuery, results, setResults, loading, setLoading, searched, setSearched, scrollPosition, setScrollPosition }) { + const navigate = useNavigate() + const scrollContainerRef = useRef(null) + + const handleSearch = async (e) => { + e.preventDefault() + if (!searchQuery.trim()) return + + setLoading(true) + setSearched(true) + + try { + const response = await axios.get(`${API_BASE}/search`, { + params: { q: searchQuery, limit: 1000 } + }) + setResults(response.data || []) + } catch (error) { + console.error('Error searching:', error) + setResults([]) + } finally { + setLoading(false) + } + } + + const handleResultClick = (result) => { + // Navigate to the conversation with the message ID as a query parameter + navigate(`/conversation/${encodeURIComponent(result.address)}?messageId=${result.message_id}`) + } + + const formatPhoneNumber = (phoneNumber) => { + if (!phoneNumber) return '' + + // Handle comma-separated numbers (group conversations) + if (phoneNumber.includes(',')) { + const numbers = phoneNumber.split(',').map(n => n.trim()) + return numbers.map(n => formatSinglePhoneNumber(n)).join(', ') + } + + return formatSinglePhoneNumber(phoneNumber) + } + + const formatSinglePhoneNumber = (phoneNumber) => { + if (!phoneNumber) return '' + + // Remove +1 prefix if present + const cleaned = phoneNumber.replace(/^\+1/, '') + + // Format as (XXX) XXX-XXXX + if (cleaned.length === 10) { + return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}` + } + + return phoneNumber + } + + // Restore scroll position when component mounts or results change + useEffect(() => { + if (scrollContainerRef.current && scrollPosition > 0) { + scrollContainerRef.current.scrollTop = scrollPosition + } + }, [scrollPosition]) + + // Save scroll position when user scrolls + const handleScroll = (e) => { + if (e.target.scrollTop !== scrollPosition) { + setScrollPosition(e.target.scrollTop) + } + } + + return ( +
+ {/* Header */} +
+

+ + + + Search Messages +

+ +
+
+ setSearchQuery(e.target.value)} + autoFocus + /> + +
+
+ + {searched && !loading && ( +
+ {results.length > 0 ? ( + <> + Found {results.length.toLocaleString()} result{results.length !== 1 ? 's' : ''} + {results.length >= 1000 && ' (limited to first 1000)'} + + ) : ( + 'No results found' + )} +
+ )} +
+ + {/* Results */} +
+ {!searched ? ( +
+ + + +

Search for messages

+

Enter a search term to find messages across all conversations

+
+ ) : loading ? ( +
+
+ Loading... +
+

Searching...

+
+ ) : results.length === 0 ? ( +
+ + + +

No results found

+

Try a different search term

+
+ ) : ( +
+ {results.map((result) => ( +
+
handleResultClick(result)} + onMouseEnter={(e) => { + e.currentTarget.style.boxShadow = '0 0.5rem 1rem rgba(0,0,0,0.15)' + e.currentTarget.style.transform = 'translateY(-2px)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.boxShadow = '' + e.currentTarget.style.transform = '' + }} + > +
+
+
+
+ {result.contact_name || formatPhoneNumber(result.address) || 'Unknown'} +
+ {result.contact_name && ( +
+ {formatPhoneNumber(result.address)} +
+ )} +
+ + {format(new Date(result.date), 'MMM d, yyyy')} + +
+
+
+
+
+ ))} +
+ )} +
+
+ ) +} + +export default Search diff --git a/frontend/src/components/Upload.jsx b/frontend/src/components/Upload.jsx new file mode 100644 index 0000000..58b9245 --- /dev/null +++ b/frontend/src/components/Upload.jsx @@ -0,0 +1,358 @@ +import { useState } from 'react' +import axios from 'axios' +import { Modal, Button, Form, Alert, Spinner, ProgressBar } from 'react-bootstrap' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +function Upload({ onClose, onSuccess }) { + const [files, setFiles] = useState([]) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [progress, setProgress] = useState(null) + const [uploadProgress, setUploadProgress] = useState(0) + const [currentStep, setCurrentStep] = useState(1) // 1 = upload, 2 = processing + const [currentFileIndex, setCurrentFileIndex] = useState(0) + const [totalFiles, setTotalFiles] = useState(0) + const [isDragging, setIsDragging] = useState(false) + + const handleFileChange = (e) => { + const selectedFiles = Array.from(e.target.files) + setFiles(selectedFiles) + setError(null) + setSuccess(null) + } + + const handleDragEnter = (e) => { + e.preventDefault() + e.stopPropagation() + if (!uploading) { + setIsDragging(true) + } + } + + const handleDragLeave = (e) => { + e.preventDefault() + e.stopPropagation() + // Only set dragging to false if we're leaving the drop zone itself + if (e.currentTarget === e.target) { + setIsDragging(false) + } + } + + const handleDragOver = (e) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDrop = (e) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + + if (uploading) return + + const droppedFiles = Array.from(e.dataTransfer.files) + // Filter to only accept XML files + const xmlFiles = droppedFiles.filter(file => file.name.toLowerCase().endsWith('.xml')) + + if (xmlFiles.length === 0) { + setError('Please drop only XML files') + return + } + + if (xmlFiles.length < droppedFiles.length) { + setError(`Only ${xmlFiles.length} of ${droppedFiles.length} files are XML files. Non-XML files were ignored.`) + } + + setFiles(xmlFiles) + setSuccess(null) + if (xmlFiles.length === droppedFiles.length) { + setError(null) + } + } + + const handleUpload = async () => { + if (files.length === 0) { + setError('Please select at least one file') + return + } + + setUploading(true) + setError(null) + setProgress(null) + setTotalFiles(files.length) + setCurrentFileIndex(0) + + try { + // Process each file sequentially + for (let i = 0; i < files.length; i++) { + const file = files[i] + setCurrentFileIndex(i + 1) + + await uploadSingleFile(file) + + // If this was the last file, show success and close + if (i === files.length - 1) { + setSuccess(`Successfully imported all ${files.length} file${files.length !== 1 ? 's' : ''}`) + setTimeout(() => { + setUploading(false) + onSuccess() + }, 1500) + } + } + } catch (err) { + console.error('Upload error:', err) + if (err.code === 'ECONNABORTED') { + setError('Upload timeout. The file may be too large.') + } else { + setError(err.response?.data?.error || err.message || 'Upload failed') + } + setUploading(false) + } + } + + const uploadSingleFile = async (file) => { + const formData = new FormData() + formData.append('file', file) + + setUploadProgress(0) + setCurrentStep(1) + + // Step 1: Upload file to server + const response = await axios.post(`${API_BASE}/upload`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 300000, // 5 minute timeout for file upload + onUploadProgress: (progressEvent) => { + // This tracks the HTTP upload progress (file transfer to disk) + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total) + setUploadProgress(percentCompleted) // Show actual upload progress 0-100% + }, + }) + + if (!response.data.success) { + throw new Error(response.data.error || 'Upload failed') + } + + // File uploaded successfully, move to step 2 + setUploadProgress(0) // Reset for processing step + setCurrentStep(2) + + // Wait for processing to complete + await waitForProcessingComplete() + } + + const waitForProcessingComplete = () => { + return new Promise((resolve, reject) => { + const checkProgress = setInterval(async () => { + try { + const response = await axios.get(`${API_BASE}/progress`) + const data = response.data + + if (!data || data.status === 'no_upload') { + clearInterval(checkProgress) + // Reject instead of resolve so the error is caught by handleUpload + reject(new Error('Processing status unavailable')) + return + } + + setProgress(data) + + // Calculate processing progress (0-100% for step 2) + const total = data.total_messages || 1 + const processed = data.processed_messages || 0 + const processingPercent = Math.min(Math.round((processed / total) * 100), 100) + setUploadProgress(processingPercent) + + // Check if completed + if (data.status === 'completed') { + clearInterval(checkProgress) + setUploadProgress(100) + // Just resolve - don't call onSuccess() here since we're processing multiple files + // The main handleUpload() function will handle success after all files are done + resolve() + } else if (data.status === 'error') { + clearInterval(checkProgress) + // Reject instead of resolve so the error is caught by handleUpload + reject(new Error(data.error_message || 'Processing failed')) + } + } catch (err) { + console.error('Error checking progress:', err) + } + }, 500) // Check every 500ms for more responsive updates + }) + } + + return ( + + + Upload Backup + + + +
+
+ + + + Select or drag and drop one or more XML files from SMS Backup & Restore app +
+ + +
+
+ + + +
+
+ {isDragging ? ( +
Drop XML files here
+ ) : ( +
+
Drag and drop XML files here
+
or
+
+ )} +
+
+ +
+
+ {files.length > 0 && !uploading && ( +
+ + + + + {files.length} file{files.length !== 1 ? 's' : ''} selected + +
+ {files.map((file, index) => ( +
+ {index + 1}. {file.name} ({(file.size / (1024 * 1024)).toFixed(2)} MB) +
+ ))} +
+
+ )} +
+
+ + {uploading && ( +
+ {totalFiles > 1 && ( +
+ + Processing file {currentFileIndex} of {totalFiles} + +
+ )} +
+ + Step {currentStep} of 2: {currentStep === 1 ? 'Uploading file' : 'Processing messages'} + + {uploadProgress}% +
+ + {currentStep === 1 && files[currentFileIndex - 1] && ( + + Uploading {files[currentFileIndex - 1].name} ({(files[currentFileIndex - 1].size / (1024 * 1024)).toFixed(2)} MB) to server... + + )} + {currentStep === 2 && progress && ( + + {progress.processed_messages?.toLocaleString() || 0} / {progress.total_messages?.toLocaleString() || '?'} messages imported + {progress.processed_calls > 0 && `, ${progress.processed_calls?.toLocaleString()} calls`} + + )} + {currentStep === 2 && !progress && ( + + Starting import process... + + )} +
+ )} + + {error && ( + + + + + {error} + + )} + + {success && ( + + + + + {success} + + )} +
+ + + + + +
+ ) +} + +export default Upload diff --git a/frontend/src/components/VCardPreview.jsx b/frontend/src/components/VCardPreview.jsx new file mode 100644 index 0000000..6d86f84 --- /dev/null +++ b/frontend/src/components/VCardPreview.jsx @@ -0,0 +1,239 @@ +import React, { useState, useEffect } from 'react' +import { parseVCard, formatAddress, formatBirthday } from '../utils/vcfParser' + +/** + * VCardPreview component for displaying vCard (contact) files + */ +function VCardPreview({ vcfText, messageId }) { + const [contact, setContact] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadVCard = () => { + try { + setLoading(true) + setError(null) + + if (!vcfText) { + throw new Error('No VCF data provided') + } + + const parsedContact = parseVCard(vcfText) + setContact(parsedContact) + } catch (err) { + console.error('Error loading vCard:', err) + setError(err.message) + } finally { + setLoading(false) + } + } + + if (vcfText) { + loadVCard() + } + }, [vcfText]) + + const handleDownload = () => { + try { + // Create blob from VCF text + const blob = new Blob([vcfText], { type: 'text/vcard' }) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${contact?.name || 'contact'}.vcf` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + } catch (err) { + console.error('Error downloading vCard:', err) + } + } + + if (loading) { + return ( +
+
+
+ Loading... +
+

Loading contact...

+
+
+ ) + } + + if (error) { + return ( +
+
+
+ + + + Error loading contact: {error} +
+
+
+ ) + } + + if (!contact) { + return null + } + + return ( +
+
+ {/* Header with photo and name */} +
+ {contact.photo ? ( + {contact.name} + ) : ( +
+ {contact.name ? contact.name.charAt(0).toUpperCase() : '?'} +
+ )} +
+
{contact.name || contact.formattedName || 'Unknown Contact'}
+ {contact.title &&

{contact.title}

} + {contact.organization &&

{contact.organization}

} +
+
+ + {/* Contact Details */} +
+ {/* Phone Numbers */} + {contact.phoneNumbers.length > 0 && ( +
+
+ + + + Phone +
+ {contact.phoneNumbers.map((phone, index) => ( +
+ {phone.type}:{' '} + + {phone.number} + +
+ ))} +
+ )} + + {/* Email Addresses */} + {contact.emails.length > 0 && ( +
+
+ + + + Email +
+ {contact.emails.map((email, index) => ( +
+ {email.type}:{' '} + + {email.address} + +
+ ))} +
+ )} + + {/* Addresses */} + {contact.addresses.length > 0 && ( +
+
+ + + + Address +
+ {contact.addresses.map((addr, index) => { + const formatted = formatAddress(addr) + return formatted ? ( +
+ {addr.type}: {formatted} +
+ ) : null + })} +
+ )} + + {/* Birthday */} + {contact.birthday && ( +
+
+ + + + Birthday +
+
{formatBirthday(contact.birthday)}
+
+ )} + + {/* URL */} + {contact.url && ( +
+
+ + + + + Website +
+ +
+ )} + + {/* Note */} + {contact.note && ( +
+
+ + + + + Note +
+
{contact.note}
+
+ )} +
+ + {/* Download Button */} +
+ +
+
+
+ ) +} + +export default VCardPreview diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..774201c --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,132 @@ +import { createContext, useContext, useState, useEffect } from 'react' +import axios from 'axios' + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api' + +const AuthContext = createContext(null) + +export function AuthProvider({ children }) { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Check if user is already authenticated on mount + useEffect(() => { + checkAuth() + }, []) + + const checkAuth = async () => { + try { + const response = await axios.get(`${API_BASE}/auth/me`, { + withCredentials: true + }) + if (response.data.success) { + setUser(response.data.user) + } + } catch (error) { + // Not authenticated, that's okay + setUser(null) + } finally { + setLoading(false) + } + } + + const login = async (username, password) => { + setError(null) + try { + const response = await axios.post( + `${API_BASE}/auth/login`, + { username, password }, + { withCredentials: true } + ) + + if (response.data.success) { + setUser(response.data.user) + return { success: true } + } else { + setError(response.data.error || 'Login failed') + return { success: false, error: response.data.error } + } + } catch (error) { + const errorMsg = error.response?.data?.error || 'Login failed. Please try again.' + setError(errorMsg) + return { success: false, error: errorMsg } + } + } + + const register = async (username, password) => { + setError(null) + try { + const response = await axios.post( + `${API_BASE}/auth/register`, + { username, password }, + { withCredentials: true } + ) + + if (response.data.success) { + setUser(response.data.user) + return { success: true } + } else { + setError(response.data.error || 'Registration failed') + return { success: false, error: response.data.error } + } + } catch (error) { + const errorMsg = error.response?.data?.error || 'Registration failed. Please try again.' + setError(errorMsg) + return { success: false, error: errorMsg } + } + } + + const logout = async () => { + try { + await axios.post(`${API_BASE}/auth/logout`, {}, { withCredentials: true }) + } catch (error) { + console.error('Logout error:', error) + } finally { + setUser(null) + } + } + + const changePassword = async (oldPassword, newPassword, confirmPassword) => { + setError(null) + try { + const response = await axios.post( + `${API_BASE}/auth/change-password`, + { old_password: oldPassword, new_password: newPassword, confirm_password: confirmPassword }, + { withCredentials: true } + ) + + if (response.data.success) { + return { success: true } + } else { + setError(response.data.error || 'Password change failed') + return { success: false, error: response.data.error } + } + } catch (error) { + const errorMsg = error.response?.data?.error || 'Password change failed. Please try again.' + setError(errorMsg) + return { success: false, error: errorMsg } + } + } + + const value = { + user, + loading, + error, + login, + register, + logout, + changePassword, + isAuthenticated: !!user + } + + return {children} +} + +export function useAuth() { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b0ea31a --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,13 @@ +body { + margin: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..bf06d3e --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,30 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import axios from 'axios' +import 'bootstrap/dist/css/bootstrap.min.css' +import './index.css' +import { AuthProvider } from './contexts/AuthContext' +import App from './App.jsx' +import Login from './components/Login.jsx' +import ProtectedRoute from './components/ProtectedRoute.jsx' + +// Configure axios to include credentials with all requests +axios.defaults.withCredentials = true + +createRoot(document.getElementById('root')).render( + + + + + } /> + + + + } /> + + + + , +) diff --git a/frontend/src/utils/vcfParser.js b/frontend/src/utils/vcfParser.js new file mode 100644 index 0000000..91a727c --- /dev/null +++ b/frontend/src/utils/vcfParser.js @@ -0,0 +1,313 @@ +/** + * Parse vCard (VCF) format and extract contact information + * Supports vCard 2.1, 3.0, and 4.0 formats + */ + +/** + * Parse a vCard file and return structured contact data + * @param {string} vcfText - The raw vCard text content + * @returns {Object} Parsed contact information + */ +export function parseVCard(vcfText) { + const contact = { + version: '', + name: '', + formattedName: '', + phoneNumbers: [], + emails: [], + addresses: [], + organization: '', + title: '', + photo: null, + birthday: '', + url: '', + note: '' + } + + // Split into lines and handle line folding (continuation lines starting with space/tab) + const lines = unfoldLines(vcfText) + + for (const line of lines) { + const [property, value] = parseVCardLine(line) + + if (!property) continue + + const { name, params } = parseProperty(property) + + switch (name.toUpperCase()) { + case 'VERSION': + contact.version = value + break + + case 'FN': + contact.formattedName = decodeValue(value, params) + break + + case 'N': + // N:LastName;FirstName;MiddleName;Prefix;Suffix + const nameParts = value.split(';').map(p => decodeValue(p, params)) + if (!contact.name) { + contact.name = [nameParts[3], nameParts[1], nameParts[2], nameParts[0], nameParts[4]] + .filter(p => p) + .join(' ') + } + break + + case 'TEL': + contact.phoneNumbers.push({ + type: getTypeLabel(params, 'phone'), + number: value + }) + break + + case 'EMAIL': + contact.emails.push({ + type: getTypeLabel(params, 'email'), + address: value + }) + break + + case 'ADR': + // ADR:;;Street;City;State;ZIP;Country + const adrParts = value.split(';').map(p => decodeValue(p, params)) + const address = { + type: getTypeLabel(params, 'address'), + street: adrParts[2], + city: adrParts[3], + state: adrParts[4], + zip: adrParts[5], + country: adrParts[6] + } + contact.addresses.push(address) + break + + case 'ORG': + contact.organization = decodeValue(value, params) + break + + case 'TITLE': + contact.title = decodeValue(value, params) + break + + case 'PHOTO': + contact.photo = parsePhoto(value, params) + break + + case 'BDAY': + contact.birthday = value + break + + case 'URL': + contact.url = value + break + + case 'NOTE': + contact.note = decodeValue(value, params) + break + } + } + + // Use formatted name if name is empty + if (!contact.name && contact.formattedName) { + contact.name = contact.formattedName + } + + return contact +} + +/** + * Unfold lines (handle line continuation in vCard format) + */ +function unfoldLines(text) { + const lines = text.split(/\r?\n/) + const unfolded = [] + let current = '' + + for (const line of lines) { + // Line continuation: starts with space or tab + if (line.startsWith(' ') || line.startsWith('\t')) { + current += line.substring(1) + } else { + if (current) { + unfolded.push(current) + } + current = line + } + } + + if (current) { + unfolded.push(current) + } + + return unfolded +} + +/** + * Parse a vCard line into property and value + */ +function parseVCardLine(line) { + const colonIndex = line.indexOf(':') + if (colonIndex === -1) return [null, null] + + const property = line.substring(0, colonIndex) + const value = line.substring(colonIndex + 1) + + return [property, value] +} + +/** + * Parse property name and parameters + * Example: "TEL;TYPE=CELL;PREF=1" => { name: "TEL", params: { TYPE: "CELL", PREF: "1" } } + */ +function parseProperty(property) { + const parts = property.split(';') + const name = parts[0] + const params = {} + + for (let i = 1; i < parts.length; i++) { + const param = parts[i] + const eqIndex = param.indexOf('=') + + if (eqIndex === -1) { + // vCard 2.1 style: TYPE without = + params['TYPE'] = params['TYPE'] ? params['TYPE'] + ',' + param : param + } else { + const paramName = param.substring(0, eqIndex) + const paramValue = param.substring(eqIndex + 1).replace(/^"(.*)"$/, '$1') + params[paramName.toUpperCase()] = paramValue + } + } + + return { name, params } +} + +/** + * Get human-readable type label from parameters + */ +function getTypeLabel(params, context) { + if (!params.TYPE) { + return context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address' + } + + const types = params.TYPE.split(',').map(t => t.toUpperCase()) + + // Common type mappings + const typeMap = { + 'CELL': 'Mobile', + 'HOME': 'Home', + 'WORK': 'Work', + 'VOICE': 'Phone', + 'FAX': 'Fax', + 'PAGER': 'Pager', + 'MSG': 'Message', + 'PREF': 'Preferred', + 'INTERNET': 'Email' + } + + const labels = types + .map(t => typeMap[t] || t.charAt(0) + t.substring(1).toLowerCase()) + .filter(l => l !== 'Internet') // Remove generic Internet label + + return labels.join(', ') || (context === 'phone' ? 'Phone' : context === 'email' ? 'Email' : 'Address') +} + +/** + * Decode value based on encoding parameter + */ +function decodeValue(value, params) { + if (!params.ENCODING) return value + + const encoding = params.ENCODING.toUpperCase() + + if (encoding === 'QUOTED-PRINTABLE') { + return decodeQuotedPrintable(value) + } + + return value +} + +/** + * Decode quoted-printable encoding + */ +function decodeQuotedPrintable(str) { + return str + .replace(/=\r?\n/g, '') // Remove soft line breaks + .replace(/=([0-9A-F]{2})/gi, (match, hex) => String.fromCharCode(parseInt(hex, 16))) +} + +/** + * Parse photo data from vCard + */ +function parsePhoto(value, params) { + const encoding = params.ENCODING ? params.ENCODING.toUpperCase() : '' + const type = params.TYPE || params.MEDIATYPE || 'JPEG' + + if (encoding === 'BASE64' || encoding === 'B') { + // Remove whitespace from base64 data + const base64Data = value.replace(/\s/g, '') + + // Determine MIME type + let mimeType = 'image/jpeg' + const typeUpper = type.toUpperCase() + + if (typeUpper.includes('PNG')) { + mimeType = 'image/png' + } else if (typeUpper.includes('GIF')) { + mimeType = 'image/gif' + } else if (typeUpper.includes('BMP')) { + mimeType = 'image/bmp' + } + + return `data:${mimeType};base64,${base64Data}` + } + + // URL reference + if (value.startsWith('http://') || value.startsWith('https://')) { + return value + } + + return null +} + +/** + * Format address object to string + */ +export function formatAddress(address) { + const parts = [ + address.street, + address.city, + address.state && address.zip ? `${address.state} ${address.zip}` : address.state || address.zip, + address.country + ].filter(p => p) + + return parts.join(', ') +} + +/** + * Format birthday to readable format + */ +export function formatBirthday(birthday) { + if (!birthday) return '' + + // Handle different date formats + // YYYYMMDD, YYYY-MM-DD, or --MMDD (no year) + if (birthday.startsWith('--')) { + const month = birthday.substring(2, 4) + const day = birthday.substring(4, 6) + return `${month}/${day}` + } + + if (birthday.includes('-')) { + const [year, month, day] = birthday.split('-') + return `${month}/${day}/${year}` + } + + if (birthday.length === 8) { + const year = birthday.substring(0, 4) + const month = birthday.substring(4, 6) + const day = birthday.substring(6, 8) + return `${month}/${day}/${year}` + } + + return birthday +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..344596a --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/lowcarbdev/sbv + +go 1.24.7 + +require github.com/mattn/go-sqlite3 v1.14.32 + +require ( + github.com/google/uuid v1.6.0 + github.com/labstack/echo/v4 v4.13.4 + github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15 + golang.org/x/crypto v0.44.0 +) + +require ( + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..68c7da4 --- /dev/null +++ b/go.sum @@ -0,0 +1,37 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15 h1:aFa2PvtQulG5uVQ8adH84JCwRZ2rjiZnRUU/mWxJRG8= +github.com/strukturag/libheif-go v0.0.0-20250130134905-55b3482bea15/go.mod h1:ZW0m/zWIvFqFSpPdiWRje8xdwyWJqt3Cnt6bVlDti8g= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth.go b/internal/auth.go new file mode 100644 index 0000000..f04703e --- /dev/null +++ b/internal/auth.go @@ -0,0 +1,205 @@ +package internal + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +var authDB *sql.DB + +// InitAuthDB initializes the authentication database +func InitAuthDB(filepath string) error { + var err error + authDB, err = sql.Open("sqlite3", filepath) + if err != nil { + return err + } + + if err = authDB.Ping(); err != nil { + return err + } + + createTableSQL := ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); + ` + + _, err = authDB.Exec(createTableSQL) + return err +} + +// CreateUser creates a new user with hashed password +func CreateUser(username, password string) (*User, error) { + // Generate UUID for user + userID := uuid.New().String() + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + createdAt := time.Now().Unix() + + _, err = authDB.Exec( + "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)", + userID, username, string(hashedPassword), createdAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + return &User{ + ID: userID, + Username: username, + PasswordHash: string(hashedPassword), + CreatedAt: time.Unix(createdAt, 0), + }, nil +} + +// GetUserByUsername retrieves a user by username +func GetUserByUsername(username string) (*User, error) { + var user User + var createdAt int64 + + err := authDB.QueryRow( + "SELECT id, username, password_hash, created_at FROM users WHERE username = ?", + username, + ).Scan(&user.ID, &user.Username, &user.PasswordHash, &createdAt) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + return nil, err + } + + user.CreatedAt = time.Unix(createdAt, 0) + return &user, nil +} + +// VerifyPassword checks if the provided password matches the user's password hash +func VerifyPassword(user *User, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) + return err == nil +} + +// GenerateSessionID generates a random session ID +func GenerateSessionID() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// CreateSession creates a new session for a user +func CreateSession(userID string, username string) (*Session, error) { + sessionID, err := GenerateSessionID() + if err != nil { + return nil, fmt.Errorf("failed to generate session ID: %w", err) + } + + createdAt := time.Now() + expiresAt := createdAt.Add(30 * 24 * time.Hour) // 30 days + + _, err = authDB.Exec( + "INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + sessionID, userID, createdAt.Unix(), expiresAt.Unix(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + return &Session{ + ID: sessionID, + UserID: userID, + Username: username, + CreatedAt: createdAt, + ExpiresAt: expiresAt, + }, nil +} + +// GetSession retrieves a session by ID +func GetSession(sessionID string) (*Session, error) { + var session Session + var createdAt, expiresAt int64 + + err := authDB.QueryRow( + `SELECT s.id, s.user_id, u.username, s.created_at, s.expires_at + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.id = ?`, + sessionID, + ).Scan(&session.ID, &session.UserID, &session.Username, &createdAt, &expiresAt) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("session not found") + } + return nil, err + } + + session.CreatedAt = time.Unix(createdAt, 0) + session.ExpiresAt = time.Unix(expiresAt, 0) + + // Check if session is expired + if time.Now().After(session.ExpiresAt) { + DeleteSession(sessionID) + return nil, fmt.Errorf("session expired") + } + + return &session, nil +} + +// DeleteSession deletes a session by ID +func DeleteSession(sessionID string) error { + _, err := authDB.Exec("DELETE FROM sessions WHERE id = ?", sessionID) + return err +} + +// CleanExpiredSessions removes all expired sessions +func CleanExpiredSessions() error { + _, err := authDB.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix()) + return err +} + +// UpdatePassword updates a user's password +func UpdatePassword(userID string, newPassword string) error { + // Hash the new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + _, err = authDB.Exec( + "UPDATE users SET password_hash = ? WHERE id = ?", + string(hashedPassword), userID, + ) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} diff --git a/internal/auth_handlers.go b/internal/auth_handlers.go new file mode 100644 index 0000000..4362024 --- /dev/null +++ b/internal/auth_handlers.go @@ -0,0 +1,278 @@ +package internal + +import ( + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +func HandleRegister(c echo.Context) error { + var req RegisterRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "Invalid request body", + }) + } + + // Validate input + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "Username is required", + }) + } + if len(req.Username) < 3 { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "Username must be at least 3 characters", + }) + } + if len(req.Password) < 6 { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "Password must be at least 6 characters", + }) + } + + // Create user + user, err := CreateUser(req.Username, req.Password) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + return c.JSON(http.StatusConflict, AuthResponse{ + Success: false, + Error: "Username already exists", + }) + } + slog.Error("Error creating user", "error", err) + return c.JSON(http.StatusInternalServerError, AuthResponse{ + Success: false, + Error: "Failed to create user", + }) + } + + // Create session + session, err := CreateSession(user.ID, user.Username) + if err != nil { + slog.Error("Error creating session", "error", err) + return c.JSON(http.StatusInternalServerError, AuthResponse{ + Success: false, + Error: "Failed to create session", + }) + } + + // Set session cookie + c.SetCookie(&http.Cookie{ + Name: "session_id", + Value: session.ID, + Expires: session.ExpiresAt, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + }) + + // Initialize user's database (using UUID as filename) + dbPathPrefix := os.Getenv("DB_PATH_PREFIX") + if dbPathPrefix == "" { + dbPathPrefix = "." + } + userDBPath := fmt.Sprintf("%s/sbv_%s.db", dbPathPrefix, user.ID) + if err := InitUserDB(user.ID, userDBPath); err != nil { + slog.Error("Error initializing user database", "error", err) + return echo.ErrInternalServerError + } + + return c.JSON(http.StatusOK, AuthResponse{ + Success: true, + User: user, + Session: session, + }) +} + +func HandleLogin(c echo.Context) error { + var req LoginRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "Invalid request body", + }) + } + + // Validate input + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" || req.Password == "" { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "Username and password are required", + }) + } + + // Get user + user, err := GetUserByUsername(req.Username) + if err != nil { + return c.JSON(http.StatusUnauthorized, AuthResponse{ + Success: false, + Error: "Invalid username or password", + }) + } + + // Verify password + if !VerifyPassword(user, req.Password) { + return c.JSON(http.StatusUnauthorized, AuthResponse{ + Success: false, + Error: "Invalid username or password", + }) + } + + // Create session + session, err := CreateSession(user.ID, user.Username) + if err != nil { + slog.Error("Error creating session", "error", err) + return c.JSON(http.StatusInternalServerError, AuthResponse{ + Success: false, + Error: "Failed to create session", + }) + } + + // Set session cookie + c.SetCookie(&http.Cookie{ + Name: "session_id", + Value: session.ID, + Expires: session.ExpiresAt, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + }) + + return c.JSON(http.StatusOK, AuthResponse{ + Success: true, + User: user, + Session: session, + }) +} + +func HandleLogout(c echo.Context) error { + // Get session ID from cookie + cookie, err := c.Cookie("session_id") + if err == nil { + // Delete session from database + DeleteSession(cookie.Value) + } + + // Clear cookie + c.SetCookie(&http.Cookie{ + Name: "session_id", + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + }) + + return c.JSON(http.StatusOK, map[string]bool{ + "success": true, + }) +} + +func HandleMe(c echo.Context) error { + // Get session from context (set by AuthMiddleware) + session, ok := c.Get("session").(*Session) + if !ok { + return c.JSON(http.StatusUnauthorized, AuthResponse{ + Success: false, + Error: "Unauthorized", + }) + } + + // Get user + user, err := GetUserByUsername(session.Username) + if err != nil { + return c.JSON(http.StatusInternalServerError, AuthResponse{ + Success: false, + Error: "Failed to get user info", + }) + } + + return c.JSON(http.StatusOK, AuthResponse{ + Success: true, + User: user, + Session: session, + }) +} + +func HandleChangePassword(c echo.Context) error { + // Get session from context (set by AuthMiddleware) + session, ok := c.Get("session").(*Session) + if !ok { + return c.JSON(http.StatusUnauthorized, AuthResponse{ + Success: false, + Error: "Unauthorized", + }) + } + + var req ChangePasswordRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "Invalid request body", + }) + } + + // Validate input + if req.OldPassword == "" || req.NewPassword == "" || req.ConfirmPassword == "" { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "All fields are required", + }) + } + + if req.NewPassword != req.ConfirmPassword { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "New passwords do not match", + }) + } + + if len(req.NewPassword) < 6 { + return c.JSON(http.StatusBadRequest, AuthResponse{ + Success: false, + Error: "New password must be at least 6 characters", + }) + } + + // Get user + user, err := GetUserByUsername(session.Username) + if err != nil { + return c.JSON(http.StatusInternalServerError, AuthResponse{ + Success: false, + Error: "Failed to get user info", + }) + } + + // Verify old password + if !VerifyPassword(user, req.OldPassword) { + return c.JSON(http.StatusUnauthorized, AuthResponse{ + Success: false, + Error: "Current password is incorrect", + }) + } + + // Update password + if err := UpdatePassword(user.ID, req.NewPassword); err != nil { + slog.Error("Error updating password", "error", err) + return c.JSON(http.StatusInternalServerError, AuthResponse{ + Success: false, + Error: "Failed to update password", + }) + } + + return c.JSON(http.StatusOK, AuthResponse{ + Success: true, + }) +} diff --git a/internal/cors_middleware.go b/internal/cors_middleware.go new file mode 100644 index 0000000..f160a74 --- /dev/null +++ b/internal/cors_middleware.go @@ -0,0 +1,39 @@ +package internal + + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +// CustomCORSMiddleware creates a custom CORS middleware that properly handles credentials +func CustomCORSMiddleware() echo.MiddlewareFunc { + allowedOrigins := map[string]bool{ + "http://localhost:5173": true, + "http://localhost:3000": true, + "http://localhost:8081": true, + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + origin := c.Request().Header.Get("Origin") + + // Check if origin is allowed + if allowedOrigins[origin] { + c.Response().Header().Set("Access-Control-Allow-Origin", origin) + c.Response().Header().Set("Access-Control-Allow-Credentials", "true") + } + + // Handle preflight requests + if c.Request().Method == http.MethodOptions { + c.Response().Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Response().Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") + c.Response().Header().Set("Access-Control-Max-Age", "3600") + return c.NoContent(http.StatusNoContent) + } + + return next(c) + } + } +} diff --git a/internal/database.go b/internal/database.go new file mode 100644 index 0000000..123f6da --- /dev/null +++ b/internal/database.go @@ -0,0 +1,909 @@ +package internal + + +import ( + "database/sql" + "fmt" + "log/slog" + "os" + "regexp" + "strings" + "sync" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +var db *sql.DB + +// userDBs stores per-user database connections (keyed by user ID) +var userDBs = make(map[string]*sql.DB) +var userDBsMutex sync.RWMutex + +// truncateString truncates a string to maxLen characters for logging +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// SanitizeUsername converts a username to a safe filesystem name +func SanitizeUsername(username string) string { + // Convert to lowercase + safe := strings.ToLower(username) + + // Replace spaces and special characters with underscores + reg := regexp.MustCompile(`[^a-z0-9]+`) + safe = reg.ReplaceAllString(safe, "_") + + // Remove leading/trailing underscores + safe = strings.Trim(safe, "_") + + // Ensure it's not empty + if safe == "" { + safe = "user" + } + + return safe +} + +func InitDB(filepath string) error { + var err error + db, err = sql.Open("sqlite3", filepath) + if err != nil { + return err + } + + if err = db.Ping(); err != nil { + return err + } + + createTableSQL := ` + -- Unified table for SMS messages, MMS messages, and call logs + -- record_type: 1 = SMS, 2 = MMS, 3 = call + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_type INTEGER NOT NULL DEFAULT 1, + address TEXT NOT NULL, + body TEXT, + type INTEGER NOT NULL, + date INTEGER NOT NULL, + read INTEGER DEFAULT 0, + thread_id INTEGER, + subject TEXT, + media_type TEXT, + media_data BLOB, + protocol INTEGER, + status INTEGER, + service_center TEXT, + sub_id INTEGER, + contact_name TEXT, + sender TEXT, + content_type TEXT, + read_report INTEGER, + read_status INTEGER, + message_id TEXT, + message_size INTEGER, + message_type INTEGER, + sim_slot INTEGER, + addresses TEXT, + duration INTEGER, + presentation INTEGER, + subscription_id TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_address ON messages(address); + CREATE INDEX IF NOT EXISTS idx_date ON messages(date); + CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); + CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type); + CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date); + + -- Create unique constraints for idempotent imports + -- record_type differentiates SMS (1), MMS (2), and calls (3) + CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0)); + + -- Create FTS5 virtual table for full-text search of messages + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_id UNINDEXED, + address UNINDEXED, + body, + contact_name UNINDEXED, + date UNINDEXED, + content='messages', + content_rowid='id' + ); + + -- Create triggers to keep FTS table in sync + CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) + VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); + END; + + CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) + VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); + END; + + CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) + VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); + INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) + VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); + END; + ` + + _, err = db.Exec(createTableSQL) + if err != nil { + return err + } + + slog.Info("Database initialized successfully") + return nil +} + +// InitUserDB initializes a database for a specific user +func InitUserDB(userID string, filepath string) error { + userDB, err := sql.Open("sqlite3", filepath) + if err != nil { + return err + } + + if err = userDB.Ping(); err != nil { + return err + } + + createTableSQL := ` + -- Unified table for SMS messages, MMS messages, and call logs + -- record_type: 1 = SMS, 2 = MMS, 3 = call + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + record_type INTEGER NOT NULL DEFAULT 1, + address TEXT NOT NULL, + body TEXT, + type INTEGER NOT NULL, + date INTEGER NOT NULL, + read INTEGER DEFAULT 0, + thread_id INTEGER, + subject TEXT, + media_type TEXT, + media_data BLOB, + protocol INTEGER, + status INTEGER, + service_center TEXT, + sub_id INTEGER, + contact_name TEXT, + sender TEXT, + content_type TEXT, + read_report INTEGER, + read_status INTEGER, + message_id TEXT, + message_size INTEGER, + message_type INTEGER, + sim_slot INTEGER, + addresses TEXT, + duration INTEGER, + presentation INTEGER, + subscription_id TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_address ON messages(address); + CREATE INDEX IF NOT EXISTS idx_date ON messages(date); + CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); + CREATE INDEX IF NOT EXISTS idx_record_type ON messages(record_type); + CREATE INDEX IF NOT EXISTS idx_record_type_date ON messages(record_type, date); + + -- record_type differentiates SMS (1), MMS (2), and calls (3) + CREATE UNIQUE INDEX IF NOT EXISTS idx_message_unique ON messages(record_type, address, date, type, COALESCE(body, ''), COALESCE(content_type, ''), COALESCE(message_id, ''), COALESCE(duration, 0)); + + CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_id UNINDEXED, + address UNINDEXED, + body, + contact_name UNINDEXED, + date UNINDEXED, + content='messages', + content_rowid='id' + ); + + CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) + VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); + END; + + CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) + VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); + END; + + CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN + INSERT INTO messages_fts(messages_fts, rowid, message_id, address, body, contact_name, date) + VALUES('delete', old.id, old.id, old.address, old.body, old.contact_name, old.date); + INSERT INTO messages_fts(rowid, message_id, address, body, contact_name, date) + VALUES (new.id, new.id, new.address, new.body, new.contact_name, new.date); + END; + ` + + _, err = userDB.Exec(createTableSQL) + if err != nil { + return err + } + + // Store in map + userDBsMutex.Lock() + userDBs[userID] = userDB + userDBsMutex.Unlock() + + slog.Info("User database initialized", "user_id", userID, "path", filepath) + return nil +} + +// GetUserDB retrieves the database connection for a specific user +func GetUserDB(userID string, username string) (*sql.DB, error) { + userDBsMutex.RLock() + defer userDBsMutex.RUnlock() + + userDB, exists := userDBs[userID] + if !exists { + // Try to open the database if it exists + dbPathPrefix := os.Getenv("DB_PATH_PREFIX") + if dbPathPrefix == "" { + dbPathPrefix = "." + } + // Use UUID as database filename instead of sanitized username + filepath := fmt.Sprintf("%s/sbv_%s.db", dbPathPrefix, userID) + if _, err := os.Stat(filepath); err == nil { + // Database file exists, try to open it + userDBsMutex.RUnlock() + if err := InitUserDB(userID, filepath); err != nil { + userDBsMutex.RLock() + return nil, fmt.Errorf("failed to open user database: %w", err) + } + userDBsMutex.RLock() + userDB = userDBs[userID] + } else { + return nil, fmt.Errorf("user database not found for user %s", username) + } + } + + return userDB, nil +} + +func InsertMessage(userDB *sql.DB, msg *Message) error { + // Convert addresses slice to JSON string + var addressesJSON string + if len(msg.Addresses) > 0 { + addresses := strings.Join(msg.Addresses, ",") + addressesJSON = addresses + } + + // Determine record type: 1 = SMS, 2 = MMS + // MMS messages have ContentType set (e.g., 'application/vnd.wap.multipart.related') + // SMS messages do not have ContentType + recordType := 1 // Default to SMS + if msg.ContentType != "" { + recordType = 2 // MMS + } + + query := ` + INSERT INTO messages ( + record_type, address, body, type, date, read, thread_id, subject, media_type, media_data, + protocol, status, service_center, sub_id, contact_name, sender, + content_type, read_report, read_status, message_id, message_size, message_type, sim_slot, addresses + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + ` + result, err := userDB.Exec(query, + recordType, // record_type: 1 = SMS, 2 = MMS + msg.Address, + msg.Body, + msg.Type, + msg.Date.Unix(), + msg.Read, + msg.ThreadID, + msg.Subject, + msg.MediaType, + msg.MediaData, + msg.Protocol, + msg.Status, + msg.ServiceCenter, + msg.SubID, + msg.ContactName, + msg.Sender, + msg.ContentType, + msg.ReadReport, + msg.ReadStatus, + msg.MessageID, + msg.MessageSize, + msg.MessageType, + msg.SimSlot, + addressesJSON, + ) + if err != nil { + slog.Debug("InsertMessage: Error inserting message", "error", err) + return err + } + + id, err := result.LastInsertId() + if err != nil { + return err + } + msg.ID = id + + return nil +} + +func InsertCallLog(userDB *sql.DB, call *CallLog) error { + query := ` + INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + ` + result, err := userDB.Exec(query, + 3, // record_type: 3 = call + call.Number, + call.Type, + call.Date.Unix(), + call.Duration, + call.Presentation, + call.SubscriptionID, + call.ContactName, + ) + if err != nil { + return err + } + + id, err := result.LastInsertId() + if err != nil { + return err + } + call.ID = id + return nil +} + +// InsertCallLogBatch inserts multiple call logs in a single transaction for better performance +func InsertCallLogBatch(userDB *sql.DB, calls []CallLog) error { + if len(calls) == 0 { + return nil + } + + tx, err := userDB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + stmt, err := tx.Prepare(` + INSERT INTO messages (record_type, address, type, date, duration, presentation, subscription_id, contact_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + `) + if err != nil { + return err + } + defer stmt.Close() + + for i := range calls { + _, err := stmt.Exec( + 3, // record_type: 3 = call + calls[i].Number, + calls[i].Type, + calls[i].Date.Unix(), + calls[i].Duration, + calls[i].Presentation, + calls[i].SubscriptionID, + calls[i].ContactName, + ) + if err != nil { + return err + } + } + + return tx.Commit() +} + +func GetConversations(userDB *sql.DB, startDate, endDate *time.Time) ([]Conversation, error) { + // Build a query that groups all activity (messages and calls) by address + query := ` + SELECT + address, + MAX(COALESCE(contact_name, '')) as contact_name, + ( + SELECT COALESCE(subject, '') + FROM messages m2 + WHERE m2.address = messages.address + AND m2.subject IS NOT NULL + AND m2.subject != '' + ORDER BY date DESC + LIMIT 1 + ) as subject, + ( + SELECT + CASE + WHEN record_type = 1 THEN body -- SMS + WHEN record_type = 2 THEN body -- MMS + WHEN record_type = 3 AND type = 1 THEN 'Incoming call' + WHEN record_type = 3 AND type = 2 THEN 'Outgoing call' + WHEN record_type = 3 AND type = 3 THEN 'Missed call' + WHEN record_type = 3 AND type = 4 THEN 'Voicemail' + WHEN record_type = 3 AND type = 5 THEN 'Rejected call' + WHEN record_type = 3 AND type = 6 THEN 'Refused call' + ELSE 'Call' + END + FROM messages m3 + WHERE m3.address = messages.address + ORDER BY date DESC + LIMIT 1 + ) as last_message, + MAX(date) as last_date, + COUNT(*) as activity_count + FROM messages + WHERE 1=1 + ` + + args := []interface{}{} + if startDate != nil { + query += " AND date >= ?" + args = append(args, startDate.Unix()) + } + if endDate != nil { + query += " AND date <= ?" + args = append(args, endDate.Unix()) + } + + query += ` + GROUP BY address + ORDER BY last_date DESC + ` + + rows, err := userDB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + conversations := []Conversation{} + for rows.Next() { + var c Conversation + var lastDateUnix int64 + var subject sql.NullString + err := rows.Scan(&c.Address, &c.ContactName, &subject, &c.LastMessage, &lastDateUnix, &c.MessageCount) + if err != nil { + return nil, err + } + c.LastDate = time.Unix(lastDateUnix, 0) + c.Subject = subject.String + c.Type = "conversation" // Changed from "message" or "call" to indicate it's a merged conversation + conversations = append(conversations, c) + } + + return conversations, nil +} + +func formatCallType(callType int) string { + switch callType { + case 1: + return "Incoming call" + case 2: + return "Outgoing call" + case 3: + return "Missed call" + case 4: + return "Voicemail" + case 5: + return "Rejected call" + case 6: + return "Refused call" + default: + return "Call" + } +} + +func GetMessages(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) { + query := ` + SELECT id, address, body, type, date, read, thread_id, + COALESCE(subject, ''), COALESCE(media_type, ''), COALESCE(media_data, ''), + COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''), + COALESCE(sub_id, 0), COALESCE(contact_name, ''), COALESCE(sender, ''), + COALESCE(content_type, ''), COALESCE(read_report, 0), COALESCE(read_status, 0), + COALESCE(message_id, ''), COALESCE(message_size, 0), COALESCE(message_type, 0), + COALESCE(sim_slot, 0), COALESCE(addresses, '') + FROM messages + WHERE record_type IN (1, 2) AND address = ? -- 1 = SMS, 2 = MMS + ` + + args := []interface{}{address} + if startDate != nil { + query += " AND date >= ?" + args = append(args, startDate.Unix()) + } + if endDate != nil { + query += " AND date <= ?" + args = append(args, endDate.Unix()) + } + + query += " ORDER BY date ASC" + + slog.Debug("GetMessages: executing query", "address", address) + slog.Debug("GetMessages: SQL query", "query", query) + slog.Debug("GetMessages: query arguments", "args", args) + + rows, err := userDB.Query(query, args...) + if err != nil { + slog.Debug("GetMessages: Query error", "error", err) + return nil, err + } + defer rows.Close() + + messages := []Message{} + for rows.Next() { + var m Message + var dateUnix int64 + var readInt int + var addressesStr string + err := rows.Scan(&m.ID, &m.Address, &m.Body, &m.Type, &dateUnix, + &readInt, &m.ThreadID, &m.Subject, &m.MediaType, &m.MediaData, + &m.Protocol, &m.Status, &m.ServiceCenter, &m.SubID, &m.ContactName, &m.Sender, + &m.ContentType, &m.ReadReport, &m.ReadStatus, &m.MessageID, + &m.MessageSize, &m.MessageType, &m.SimSlot, &addressesStr) + if err != nil { + return nil, err + } + m.Date = time.Unix(dateUnix, 0) + m.Read = readInt == 1 + + // Parse addresses from comma-separated string + if addressesStr != "" { + m.Addresses = strings.Split(addressesStr, ",") + } + + // Don't load media data - it will be fetched on demand via /api/media + // Clear MediaData to save memory in response + m.MediaData = nil + + slog.Debug("GetMessages: Message", "id", m.ID, "address", m.Address, "media_type", m.MediaType, "body", truncateString(m.Body, 50)) + + messages = append(messages, m) + } + + slog.Debug("GetMessages: Returning messages", "count", len(messages), "address", address) + return messages, nil +} + +func GetCallLogs(userDB *sql.DB, number string, startDate, endDate *time.Time) ([]CallLog, error) { + query := ` + SELECT id, address, duration, date, type, + COALESCE(presentation, 0), COALESCE(subscription_id, ''), COALESCE(contact_name, '') + FROM messages + WHERE record_type = 3 AND address = ? -- 3 = call + ` + + args := []interface{}{number} + if startDate != nil { + query += " AND date >= ?" + args = append(args, startDate.Unix()) + } + if endDate != nil { + query += " AND date <= ?" + args = append(args, endDate.Unix()) + } + + query += " ORDER BY date ASC" + + rows, err := userDB.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + calls := []CallLog{} + for rows.Next() { + var c CallLog + var dateUnix int64 + err := rows.Scan(&c.ID, &c.Number, &c.Duration, &dateUnix, &c.Type, + &c.Presentation, &c.SubscriptionID, &c.ContactName) + if err != nil { + return nil, err + } + c.Date = time.Unix(dateUnix, 0) + calls = append(calls, c) + } + + return calls, nil +} + +func GetActivity(userDB *sql.DB, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) { + return GetActivityByAddress(userDB, "", startDate, endDate, limit, offset) +} + +func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time, limit, offset int) ([]ActivityItem, error) { + var activities []ActivityItem + + // Query from unified table + query := ` + SELECT record_type, date, address, COALESCE(contact_name, '') as contact_name, + id, body, type, read, thread_id, COALESCE(subject, ''), + COALESCE(media_type, ''), COALESCE(media_data, ''), + COALESCE(protocol, 0), COALESCE(status, 0), COALESCE(service_center, ''), + COALESCE(sub_id, 0), COALESCE(content_type, ''), COALESCE(read_report, 0), + COALESCE(read_status, 0), COALESCE(message_id, ''), COALESCE(message_size, 0), + COALESCE(message_type, 0), COALESCE(sim_slot, 0), COALESCE(addresses, ''), + COALESCE(duration, 0), COALESCE(presentation, 0), COALESCE(subscription_id, ''), + COALESCE(sender, '') + FROM messages + WHERE 1=1 + ` + + args := []interface{}{} + if address != "" { + query += " AND address = ?" + args = append(args, address) + } + if startDate != nil { + query += " AND date >= ?" + args = append(args, startDate.Unix()) + } + if endDate != nil { + query += " AND date <= ?" + args = append(args, endDate.Unix()) + } + + query += " ORDER BY date ASC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + slog.Debug("GetActivityByAddress: executing query", "address", address, "limit", limit, "offset", offset) + slog.Debug("GetActivityByAddress: SQL query", "query", query) + slog.Debug("GetActivityByAddress: query arguments", "args", args) + + rows, err := userDB.Query(query, args...) + if err != nil { + slog.Debug("GetActivityByAddress: Query error", "error", err) + return nil, err + } + defer rows.Close() + + for rows.Next() { + var recordType int64 + var dateUnix int64 + var address, contactName string + + // Shared fields + var id sql.NullInt64 + var itemType sql.NullInt64 // type field - used for both message type and call type + + // Message fields + var body, subject, mediaType, serviceCenter, contentType, messageID, subscriptionID, addressesStr, sender sql.NullString + var readInt, threadID, protocol, status, subID, readReport, readStatus, messageSize, messageTypeField, simSlot sql.NullInt64 + var mediaData []byte + + // Call fields + var duration, presentation sql.NullInt64 + + err := rows.Scan(&recordType, &dateUnix, &address, &contactName, + &id, &body, &itemType, &readInt, &threadID, &subject, + &mediaType, &mediaData, + &protocol, &status, &serviceCenter, + &subID, &contentType, &readReport, + &readStatus, &messageID, &messageSize, + &messageTypeField, &simSlot, &addressesStr, + &duration, &presentation, &subscriptionID, &sender) + if err != nil { + return nil, err + } + + var activityTypeStr string + if recordType == 1 || recordType == 2 { + // 1 = SMS, 2 = MMS + activityTypeStr = "message" + } else if recordType == 3 { + // 3 = call + activityTypeStr = "call" + } + + activity := ActivityItem{ + Type: activityTypeStr, + Date: time.Unix(dateUnix, 0), + Address: address, + ContactName: contactName, + } + + if (recordType == 1 || recordType == 2) && id.Valid { + // Handle SMS (1) and MMS (2) + msg := &Message{ + ID: id.Int64, + Address: address, + Body: body.String, + Date: time.Unix(dateUnix, 0), + ThreadID: int(threadID.Int64), + Subject: subject.String, + MediaType: mediaType.String, + MediaData: mediaData, + Protocol: int(protocol.Int64), + Status: int(status.Int64), + ServiceCenter: serviceCenter.String, + SubID: int(subID.Int64), + ContactName: contactName, + ContentType: contentType.String, + ReadReport: int(readReport.Int64), + ReadStatus: int(readStatus.Int64), + MessageID: messageID.String, + MessageSize: int(messageSize.Int64), + MessageType: int(messageTypeField.Int64), + SimSlot: int(simSlot.Int64), + Sender: sender.String, + } + if itemType.Valid { + msg.Type = int(itemType.Int64) + } + if readInt.Valid { + msg.Read = readInt.Int64 == 1 + } + + // Parse addresses from comma-separated string + slog.Debug("GetActivityByAddress: addressesStr raw", "id", id.Int64, "valid", addressesStr.Valid, "value", addressesStr.String) + if addressesStr.Valid && addressesStr.String != "" { + msg.Addresses = strings.Split(addressesStr.String, ",") + slog.Debug("GetActivityByAddress: addresses split result", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses) + } else if strings.Contains(address, ",") { + // Fallback: If addresses field is empty but address contains commas, + // this is a group conversation - parse the address field + msg.Addresses = strings.Split(address, ",") + slog.Debug("GetActivityByAddress: addresses from address field", "id", id.Int64, "count", len(msg.Addresses), "values", msg.Addresses) + } + + // Don't load media data - it will be fetched on demand via /api/media + // Clear MediaData to save memory in response + msg.MediaData = nil + + slog.Debug("GetActivityByAddress: Message", "id", msg.ID, "address", msg.Address, "type", msg.Type, "sender", msg.Sender, "addresses", msg.Addresses, "media_type", msg.MediaType, "body", truncateString(msg.Body, 50)) + + activity.Message = msg + } else if recordType == 3 && id.Valid { + // Handle calls (3) + call := &CallLog{ + ID: id.Int64, + Number: address, + Duration: int(duration.Int64), + Date: time.Unix(dateUnix, 0), + Type: int(itemType.Int64), + Presentation: int(presentation.Int64), + SubscriptionID: subscriptionID.String, + ContactName: contactName, + } + slog.Debug("GetActivityByAddress: Call", "id", call.ID, "number", call.Number, "type", call.Type, "duration", call.Duration) + activity.Call = call + } + + activities = append(activities, activity) + } + + slog.Debug("GetActivityByAddress: Returning activities", "count", len(activities), "address", address) + return activities, nil +} + +func GetMessageMedia(userDB *sql.DB, messageID string) ([]byte, string, error) { + query := ` + SELECT COALESCE(media_data, ''), COALESCE(media_type, '') + FROM messages + WHERE id = ? AND record_type IN (1, 2) -- 1 = SMS, 2 = MMS + ` + + slog.Debug("GetMessageMedia: Fetching media", "message_id", messageID) + slog.Debug("GetMessageMedia: SQL query", "query", query) + + var mediaData []byte + var mediaType string + + err := userDB.QueryRow(query, messageID).Scan(&mediaData, &mediaType) + if err != nil { + slog.Debug("GetMessageMedia: Error scanning row", "message_id", messageID, "error", err) + return nil, "", err + } + + slog.Debug("GetMessageMedia: Found media", "media_type", mediaType, "data_length", len(mediaData), "message_id", messageID) + + if len(mediaData) == 0 || mediaType == "" { + slog.Debug("GetMessageMedia: No media found", "message_id", messageID) + return nil, "", fmt.Errorf("no media found") + } + + // Convert HEIC to JPEG if needed + if isHEICContentType(mediaType) { + convertedData, err := convertHEICtoJPEG(mediaData) + if err != nil { + slog.Error("Failed to convert HEIC to JPEG", "message_id", messageID, "error", err) + // Return original if conversion fails + return mediaData, mediaType, nil + } + return convertedData, "image/jpeg", nil + } + + // Convert unsupported video formats (3GP, etc.) to MP4 if needed + if needsVideoConversion(mediaType) { + slog.Info("Converting video to MP4", "from_type", mediaType, "message_id", messageID) + convertedData, err := convertVideoToMP4(mediaData) + if err != nil { + slog.Error("Failed to convert video to MP4", "message_id", messageID, "error", err) + // Return original if conversion fails + return mediaData, mediaType, nil + } + slog.Info("Successfully converted video to MP4", "message_id", messageID) + return convertedData, "video/mp4", nil + } + + return mediaData, mediaType, nil +} + +func GetDateRange(userDB *sql.DB) (time.Time, time.Time, error) { + var minDate, maxDate int64 + + // Get min/max from unified messages table + query := "SELECT MIN(date), MAX(date) FROM messages" + var min, max sql.NullInt64 + err := userDB.QueryRow(query).Scan(&min, &max) + if err != nil && err != sql.ErrNoRows { + return time.Time{}, time.Time{}, err + } + + if !min.Valid || !max.Valid { + return time.Time{}, time.Time{}, fmt.Errorf("no data available") + } + + minDate = min.Int64 + maxDate = max.Int64 + + return time.Unix(minDate, 0), time.Unix(maxDate, 0), nil +} + +// SearchResult represents a message search result +type SearchResult struct { + MessageID int64 `json:"message_id"` + Address string `json:"address"` + ContactName string `json:"contact_name"` + Body string `json:"body"` + Date time.Time `json:"date"` + Snippet string `json:"snippet"` +} + +// SearchMessages performs full-text search on message contents +func SearchMessages(userDB *sql.DB, query string, limit int) ([]SearchResult, error) { + if query == "" { + return []SearchResult{}, nil + } + + sqlQuery := ` + SELECT + m.id, + m.address, + COALESCE(m.contact_name, ''), + m.body, + m.date, + snippet(messages_fts, 2, '', '', '...', 50) as snippet + FROM messages_fts + JOIN messages m ON messages_fts.rowid = m.id + WHERE messages_fts MATCH ? + ORDER BY rank + LIMIT ? + ` + + rows, err := userDB.Query(sqlQuery, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + results := []SearchResult{} + for rows.Next() { + var r SearchResult + var dateUnix int64 + err := rows.Scan(&r.MessageID, &r.Address, &r.ContactName, &r.Body, &dateUnix, &r.Snippet) + if err != nil { + return nil, err + } + r.Date = time.Unix(dateUnix, 0) + results = append(results, r) + } + + return results, nil +} diff --git a/internal/handlers.go b/internal/handlers.go new file mode 100644 index 0000000..b125851 --- /dev/null +++ b/internal/handlers.go @@ -0,0 +1,359 @@ +package internal + + +import ( + "database/sql" + "fmt" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/labstack/echo/v4" +) + +// getUserDB is a helper function to get the user's database connection from the context +func getUserDB(c echo.Context) (*sql.DB, error) { + userID, ok := c.Get("user_id").(string) + if !ok { + return nil, fmt.Errorf("user_id not found in context") + } + username, ok := c.Get("username").(string) + if !ok { + return nil, fmt.Errorf("username not found in context") + } + return GetUserDB(userID, username) +} + +func HandleUpload(c echo.Context) error { + // Use a smaller memory limit for the form parsing itself (32 MB) + // Large files will be streamed directly to disk + err := c.Request().ParseMultipartForm(32 << 20) // 32 MB max in memory + if err != nil { + slog.Error("Error parsing form", "error", err) + return c.JSON(http.StatusBadRequest, UploadResponse{ + Success: false, + Error: "Failed to parse form data. File may be too large or corrupted.", + }) + } + + file, header, err := c.Request().FormFile("file") + if err != nil { + slog.Error("Error getting file", "error", err) + return c.JSON(http.StatusBadRequest, UploadResponse{ + Success: false, + Error: "Failed to get file from form", + }) + } + defer file.Close() + + slog.Info("Receiving file", "filename", header.Filename, "size", header.Size) + + // Save uploaded file to temporary location first + tempFilePath, err := SaveUploadedFile(file, header.Filename) + if err != nil { + slog.Error("Error saving file", "error", err) + return c.JSON(http.StatusInternalServerError, UploadResponse{ + Success: false, + Error: "Failed to save uploaded file: " + err.Error(), + }) + } + + slog.Info("File saved", "path", tempFilePath) + + // Get user ID from context + userID, ok := c.Get("user_id").(string) + if !ok { + return c.JSON(http.StatusUnauthorized, UploadResponse{ + Success: false, + Error: "User not authenticated", + }) + } + + // Get username from context + username, ok := c.Get("username").(string) + if !ok { + return c.JSON(http.StatusUnauthorized, UploadResponse{ + Success: false, + Error: "User not authenticated", + }) + } + + // Start background processing with user context + go ProcessUploadedFile(userID, username, tempFilePath) + + // Return immediately - client will poll /api/progress for status + return c.JSON(http.StatusOK, UploadResponse{ + Success: true, + MessageCount: 0, + CallLogCount: 0, + Processing: true, + }) +} + +func HandleConversations(c echo.Context) error { + userDB, err := getUserDB(c) + if err != nil { + slog.Error("Error getting user database", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get user database", + }) + } + + var startDate, endDate *time.Time + + if startStr := c.QueryParam("start"); startStr != "" { + t, err := time.Parse(time.RFC3339, startStr) + if err == nil { + startDate = &t + } + } + + if endStr := c.QueryParam("end"); endStr != "" { + t, err := time.Parse(time.RFC3339, endStr) + if err == nil { + endDate = &t + } + } + + conversations, err := GetConversations(userDB, startDate, endDate) + if err != nil { + slog.Error("Error getting conversations", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get conversations", + }) + } + + return c.JSON(http.StatusOK, conversations) +} + +func HandleMessages(c echo.Context) error { + userDB, err := getUserDB(c) + if err != nil { + slog.Error("Error getting user database", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get user database", + }) + } + + address := c.QueryParam("address") + convType := c.QueryParam("type") + if address == "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Address parameter required", + }) + } + + var startDate, endDate *time.Time + + if startStr := c.QueryParam("start"); startStr != "" { + t, err := time.Parse(time.RFC3339, startStr) + if err == nil { + startDate = &t + } + } + + if endStr := c.QueryParam("end"); endStr != "" { + t, err := time.Parse(time.RFC3339, endStr) + if err == nil { + endDate = &t + } + } + + // If type is "call", return call logs instead of messages + if convType == "call" { + calls, err := GetCallLogs(userDB, address, startDate, endDate) + if err != nil { + slog.Error("Error getting call logs", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get call logs", + }) + } + return c.JSON(http.StatusOK, calls) + } + + // If type is "conversation", return combined messages and calls + if convType == "conversation" { + // Use a large limit to get all activity for this address + // We don't use pagination here since we want all items for the thread view + activities, err := GetActivityByAddress(userDB, address, startDate, endDate, 10000, 0) + if err != nil { + slog.Error("Error getting activity", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get activity", + }) + } + return c.JSON(http.StatusOK, activities) + } + + messages, err := GetMessages(userDB, address, startDate, endDate) + if err != nil { + slog.Error("Error getting messages", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get messages", + }) + } + + return c.JSON(http.StatusOK, messages) +} + +func HandleActivity(c echo.Context) error { + userDB, err := getUserDB(c) + if err != nil { + slog.Error("Error getting user database", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get user database", + }) + } + + var startDate, endDate *time.Time + + if startStr := c.QueryParam("start"); startStr != "" { + t, err := time.Parse(time.RFC3339, startStr) + if err == nil { + startDate = &t + } + } + + if endStr := c.QueryParam("end"); endStr != "" { + t, err := time.Parse(time.RFC3339, endStr) + if err == nil { + endDate = &t + } + } + + // Parse pagination parameters + limit := 50 // default limit + offset := 0 // default offset + + if limitStr := c.QueryParam("limit"); limitStr != "" { + if val, err := strconv.Atoi(limitStr); err == nil { + limit = val + } + } + + if offsetStr := c.QueryParam("offset"); offsetStr != "" { + if val, err := strconv.Atoi(offsetStr); err == nil { + offset = val + } + } + + activities, err := GetActivity(userDB, startDate, endDate, limit, offset) + if err != nil { + slog.Error("Error getting activity", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get activity", + }) + } + + return c.JSON(http.StatusOK, activities) +} + +func HandleDateRange(c echo.Context) error { + userDB, err := getUserDB(c) + if err != nil { + slog.Error("Error getting user database", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get user database", + }) + } + + minDate, maxDate, err := GetDateRange(userDB) + if err != nil { + slog.Error("Error getting date range", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get date range", + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "min_date": minDate, + "max_date": maxDate, + }) +} + +func HandleProgress(c echo.Context) error { + progress := GetUploadProgress() + if progress == nil { + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "no_upload", + }) + } + + return c.JSON(http.StatusOK, progress) +} + +func HandleMedia(c echo.Context) error { + userDB, err := getUserDB(c) + if err != nil { + slog.Error("Error getting user database", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get user database", + }) + } + + // Get message ID from query parameter + messageID := c.QueryParam("id") + if messageID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Message ID required", + }) + } + + // Fetch media from database + media, contentType, err := GetMessageMedia(userDB, messageID) + if err != nil { + slog.Error("Error getting media", "error", err) + return c.JSON(http.StatusNotFound, map[string]string{ + "error": "Media not found", + }) + } + + if len(media) == 0 { + return c.JSON(http.StatusNotFound, map[string]string{ + "error": "No media for this message", + }) + } + + // Set appropriate headers + c.Response().Header().Set("Cache-Control", "public, max-age=31536000") // Cache for 1 year + c.Response().Header().Set("Content-Length", fmt.Sprintf("%d", len(media))) + + // Write binary data with proper content type + return c.Blob(http.StatusOK, contentType, media) +} + +func HandleSearch(c echo.Context) error { + userDB, err := getUserDB(c) + if err != nil { + slog.Error("Error getting user database", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to get user database", + }) + } + + // Get search query from query parameter + query := c.QueryParam("q") + if query == "" { + return c.JSON(http.StatusOK, []SearchResult{}) + } + + // Get limit from query parameter, default to 100 + limit := 100 + if limitStr := c.QueryParam("limit"); limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + } + } + + // Perform search + results, err := SearchMessages(userDB, query, limit) + if err != nil { + slog.Error("Error searching messages", "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Search failed: " + err.Error(), + }) + } + + return c.JSON(http.StatusOK, results) +} diff --git a/internal/handlers_test.go b/internal/handlers_test.go new file mode 100644 index 0000000..2f45afa --- /dev/null +++ b/internal/handlers_test.go @@ -0,0 +1,570 @@ +package internal + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v4" +) + +// Global test user ID - stored here so setupTestContext can use it +var testUserID string + +// setupTestDB creates a test database with sample data +func setupTestDB(t *testing.T) (string, func()) { + tmpDB := "test_handlers.db" + tmpAuthDB := "test_handlers_auth.db" + + // Clean up any existing test database + os.Remove(tmpDB) + os.Remove(tmpAuthDB) + + // Initialize auth database first + if err := InitAuthDB(tmpAuthDB); err != nil { + t.Fatalf("Failed to initialize auth database: %v", err) + } + + // Initialize main database + if err := InitDB(tmpDB); err != nil { + t.Fatalf("Failed to initialize database: %v", err) + } + + // Create test user + user, err := CreateUser("testuser", "password123") + if err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + + // Store user ID globally for setupTestContext to use + testUserID = user.ID + + // Initialize user database (using UUID-based filename) + userDBPath := fmt.Sprintf("sbv_%s.db", user.ID) + if err := InitUserDB(user.ID, userDBPath); err != nil { + t.Fatalf("Failed to initialize user database: %v", err) + } + + // Get user database connection + userDB, err := GetUserDB(user.ID, user.Username) + if err != nil { + t.Fatalf("Failed to get user database: %v", err) + } + + // Insert test messages + sampleXML := ` + + + + + + + + + + + + +` + + reader := strings.NewReader(sampleXML) + result, err := ParseSMSBackup(reader) + if err != nil { + t.Fatalf("Failed to parse XML: %v", err) + } + + for i := range result.Messages { + if err := InsertMessage(userDB, &result.Messages[i]); err != nil { + t.Fatalf("Failed to insert message: %v", err) + } + } + + // Return cleanup function + cleanup := func() { + if db != nil { + db.Close() + } + if userDB != nil { + userDB.Close() + } + os.Remove(tmpDB) + os.Remove(tmpAuthDB) + os.Remove(userDBPath) + } + + return tmpDB, cleanup +} + +// setupTestContext creates an Echo context with user authentication +func setupTestContext(method, url string, body string) (echo.Context, *httptest.ResponseRecorder) { + e := echo.New() + var req *http.Request + if body != "" { + req = httptest.NewRequest(method, url, strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + } else { + req = httptest.NewRequest(method, url, nil) + } + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + // Set user context (simulating authentication middleware) + // Use the global testUserID which was set by setupTestDB + c.Set("user_id", testUserID) + c.Set("username", "testuser") + + return c, rec +} + +func TestHealthEndpoint(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/health", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + handler := func(c echo.Context) error { + return c.String(http.StatusOK, "OK") + } + + if err := handler(c); err != nil { + t.Fatalf("Health check failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + if rec.Body.String() != "OK" { + t.Errorf("Expected body 'OK', got '%s'", rec.Body.String()) + } +} + +func TestHandleConversations(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/conversations", "") + + if err := HandleConversations(c); err != nil { + t.Fatalf("HandleConversations failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var conversations []Conversation + if err := json.Unmarshal(rec.Body.Bytes(), &conversations); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should have 2 conversations (one for +15551234567, one for +15559876543) + if len(conversations) < 1 { + t.Errorf("Expected at least 1 conversation, got %d", len(conversations)) + } +} + +func TestHandleConversationsWithDateRange(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + // Test with date range that includes all messages + start := time.Unix(1285799668, 0).Add(-time.Hour).Format(time.RFC3339) + end := time.Unix(1285799671, 0).Format(time.RFC3339) + + c, rec := setupTestContext(http.MethodGet, "/api/conversations?start="+start+"&end="+end, "") + c.QueryParams().Add("start", start) + c.QueryParams().Add("end", end) + + if err := HandleConversations(c); err != nil { + t.Fatalf("HandleConversations with date range failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var conversations []Conversation + if err := json.Unmarshal(rec.Body.Bytes(), &conversations); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if len(conversations) < 1 { + t.Errorf("Expected at least 1 conversation, got %d", len(conversations)) + } +} + +func TestHandleMessages(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + // First get conversations to find a valid address + c1, rec1 := setupTestContext(http.MethodGet, "/api/conversations", "") + if err := HandleConversations(c1); err != nil { + t.Fatalf("HandleConversations failed: %v", err) + } + + var conversations []Conversation + if err := json.Unmarshal(rec1.Body.Bytes(), &conversations); err != nil { + t.Fatalf("Failed to parse conversations: %v", err) + } + + if len(conversations) == 0 { + t.Fatal("No conversations found in test database") + } + + // Use the first conversation's address + testAddress := conversations[0].Address + + c, rec := setupTestContext(http.MethodGet, "/api/messages?address="+testAddress, "") + c.QueryParams().Add("address", testAddress) + + if err := HandleMessages(c); err != nil { + t.Fatalf("HandleMessages failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var messages []Message + if err := json.Unmarshal(rec.Body.Bytes(), &messages); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Verify the response is valid JSON array (might be empty if address format doesn't match) + // The important thing is that the handler responds correctly + t.Logf("Got %d messages for address %s", len(messages), testAddress) +} + +func TestHandleMessagesWithoutAddress(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/messages", "") + + if err := HandleMessages(c); err != nil { + t.Fatalf("HandleMessages failed: %v", err) + } + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rec.Code) + } + + var response map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse error response: %v", err) + } + + if !strings.Contains(response["error"], "Address parameter required") { + t.Errorf("Expected error about missing address, got: %s", response["error"]) + } +} + +func TestHandleMessagesConversationType(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + // First get conversations to find a valid address + c1, rec1 := setupTestContext(http.MethodGet, "/api/conversations", "") + if err := HandleConversations(c1); err != nil { + t.Fatalf("HandleConversations failed: %v", err) + } + + var conversations []Conversation + if err := json.Unmarshal(rec1.Body.Bytes(), &conversations); err != nil { + t.Fatalf("Failed to parse conversations: %v", err) + } + + if len(conversations) == 0 { + t.Fatal("No conversations found in test database") + } + + // Use the first conversation's address + testAddress := conversations[0].Address + + c, rec := setupTestContext(http.MethodGet, "/api/messages?address="+testAddress+"&type=conversation", "") + c.QueryParams().Add("address", testAddress) + c.QueryParams().Add("type", "conversation") + + if err := HandleMessages(c); err != nil { + t.Fatalf("HandleMessages with type=conversation failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var activities []ActivityItem + if err := json.Unmarshal(rec.Body.Bytes(), &activities); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Verify the response is valid JSON array (might be empty if address format doesn't match) + // The important thing is that the handler responds correctly with type=conversation + t.Logf("Got %d activities for address %s with type=conversation", len(activities), testAddress) +} + +func TestHandleActivity(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/activity", "") + + if err := HandleActivity(c); err != nil { + t.Fatalf("HandleActivity failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var activities []ActivityItem + if err := json.Unmarshal(rec.Body.Bytes(), &activities); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should have 3 activities (2 SMS + 1 MMS) + if len(activities) != 3 { + t.Errorf("Expected 3 activities, got %d", len(activities)) + } +} + +func TestHandleActivityWithPagination(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/activity?limit=1&offset=0", "") + c.QueryParams().Add("limit", "1") + c.QueryParams().Add("offset", "0") + + if err := HandleActivity(c); err != nil { + t.Fatalf("HandleActivity with pagination failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var activities []ActivityItem + if err := json.Unmarshal(rec.Body.Bytes(), &activities); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should have exactly 1 activity due to limit + if len(activities) != 1 { + t.Errorf("Expected 1 activity, got %d", len(activities)) + } +} + +func TestHandleDateRange(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/daterange", "") + + if err := HandleDateRange(c); err != nil { + t.Fatalf("HandleDateRange failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response["min_date"] == nil { + t.Error("Expected min_date in response") + } + + if response["max_date"] == nil { + t.Error("Expected max_date in response") + } +} + +func TestHandleMedia(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + // Test without message ID + c, rec := setupTestContext(http.MethodGet, "/api/media", "") + + if err := HandleMedia(c); err != nil { + t.Fatalf("HandleMedia failed: %v", err) + } + + if rec.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rec.Code) + } + + var response map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse error response: %v", err) + } + + if !strings.Contains(response["error"], "Message ID required") { + t.Errorf("Expected error about missing message ID, got: %s", response["error"]) + } +} + +func TestHandleMediaNotFound(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/media?id=99999", "") + c.QueryParams().Add("id", "99999") + + if err := HandleMedia(c); err != nil { + t.Fatalf("HandleMedia failed: %v", err) + } + + if rec.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", rec.Code) + } +} + +func TestHandleSearch(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/search?q=Test", "") + c.QueryParams().Add("q", "Test") + + if err := HandleSearch(c); err != nil { + t.Fatalf("HandleSearch failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var results []SearchResult + if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should find messages containing "Test" + if len(results) < 1 { + t.Errorf("Expected at least 1 search result, got %d", len(results)) + } +} + +func TestHandleSearchEmpty(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/search", "") + + if err := HandleSearch(c); err != nil { + t.Fatalf("HandleSearch failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var results []SearchResult + if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should return empty array for empty query + if len(results) != 0 { + t.Errorf("Expected 0 search results for empty query, got %d", len(results)) + } +} + +func TestHandleSearchWithLimit(t *testing.T) { + _, cleanup := setupTestDB(t) + defer cleanup() + + c, rec := setupTestContext(http.MethodGet, "/api/search?q=Test&limit=1", "") + c.QueryParams().Add("q", "Test") + c.QueryParams().Add("limit", "1") + + if err := HandleSearch(c); err != nil { + t.Fatalf("HandleSearch with limit failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var results []SearchResult + if err := json.Unmarshal(rec.Body.Bytes(), &results); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should respect the limit + if len(results) > 1 { + t.Errorf("Expected at most 1 search result, got %d", len(results)) + } +} + +func TestHandleProgress(t *testing.T) { + c, rec := setupTestContext(http.MethodGet, "/api/progress", "") + + if err := HandleProgress(c); err != nil { + t.Fatalf("HandleProgress failed: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rec.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should return no_upload status when no upload is in progress + if response["status"] != "no_upload" { + t.Errorf("Expected status 'no_upload', got '%v'", response["status"]) + } +} + +func TestGetUserDBHelperMissingUserID(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + // Don't set user_id in context + _, err := getUserDB(c) + + if err == nil { + t.Error("Expected error when user_id is missing") + } + + if !strings.Contains(err.Error(), "user_id not found") { + t.Errorf("Expected error about missing user_id, got: %v", err) + } +} + +func TestGetUserDBHelperMissingUsername(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/api/test", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + // Set user_id but not username + c.Set("user_id", "test-user-id") + + _, err := getUserDB(c) + + if err == nil { + t.Error("Expected error when username is missing") + } + + if !strings.Contains(err.Error(), "username not found") { + t.Errorf("Expected error about missing username, got: %v", err) + } +} diff --git a/internal/heic_disabled.go b/internal/heic_disabled.go new file mode 100644 index 0000000..e4fef63 --- /dev/null +++ b/internal/heic_disabled.go @@ -0,0 +1,67 @@ +//go:build !heic + +package internal + + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/color" + "image/jpeg" + "log/slog" +) + +// convertHEICtoJPEG returns a placeholder image when HEIC support is disabled +// This version does not require the libheif library +func convertHEICtoJPEG(heicData []byte) ([]byte, error) { + slog.Warn("HEIC conversion is disabled. Returning placeholder image. Build with -tags heic to enable HEIC support.") + + // Return a simple placeholder JPEG image (400x300 gray rectangle with text) + // This is better than returning an error, as it allows the app to function + return generatePlaceholderJPEG() +} + +// generatePlaceholderJPEG creates a simple gray placeholder image +func generatePlaceholderJPEG() ([]byte, error) { + // Create a 400x300 image + width, height := 400, 300 + img := image.NewRGBA(image.Rect(0, 0, width, height)) + + // Fill with gray background + gray := color.RGBA{200, 200, 200, 255} + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.Set(x, y, gray) + } + } + + // Add a dark border + borderColor := color.RGBA{100, 100, 100, 255} + for x := 0; x < width; x++ { + img.Set(x, 0, borderColor) + img.Set(x, height-1, borderColor) + } + for y := 0; y < height; y++ { + img.Set(0, y, borderColor) + img.Set(width-1, y, borderColor) + } + + // Encode as JPEG + var buf bytes.Buffer + err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}) + if err != nil { + return nil, fmt.Errorf("failed to encode placeholder image: %w", err) + } + + return buf.Bytes(), nil +} + +// Alternative: Return a base64-encoded minimal JPEG (1x1 pixel) +// This is more efficient but less user-friendly +func generateMinimalPlaceholderJPEG() ([]byte, error) { + // 1x1 gray pixel JPEG (base64 encoded minimal JPEG) + minimalJPEG := "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/wA/h" + return base64.StdEncoding.DecodeString(minimalJPEG) +} diff --git a/internal/heic_enabled.go b/internal/heic_enabled.go new file mode 100644 index 0000000..bd656e6 --- /dev/null +++ b/internal/heic_enabled.go @@ -0,0 +1,55 @@ +//go:build heic + +package internal + + +import ( + "bytes" + "image/jpeg" + + "github.com/strukturag/libheif-go" +) + +// convertHEICtoJPEG converts HEIC image data to JPEG format +// Returns the converted JPEG data or an error if conversion fails +// This version requires the libheif library and is enabled with the 'heic' build tag +func convertHEICtoJPEG(heicData []byte) ([]byte, error) { + // Create a new HEIF context + ctx, err := libheif.NewContext() + if err != nil { + return nil, err + } + + // Read HEIC data from memory + err = ctx.ReadFromMemory(heicData) + if err != nil { + return nil, err + } + + // Get the primary image handle + handle, err := ctx.GetPrimaryImageHandle() + if err != nil { + return nil, err + } + + // Decode the image to RGB format + img, err := handle.DecodeImage(libheif.ColorspaceRGB, libheif.ChromaInterleavedRGB, nil) + if err != nil { + return nil, err + } + + // Convert to Go's standard image.Image + goImg, err := img.GetImage() + if err != nil { + return nil, err + } + + // Encode as JPEG with high quality + var buf bytes.Buffer + err = jpeg.Encode(&buf, goImg, &jpeg.Options{Quality: 90}) + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/internal/middleware.go b/internal/middleware.go new file mode 100644 index 0000000..742c177 --- /dev/null +++ b/internal/middleware.go @@ -0,0 +1,49 @@ +package internal + + +import ( + "net/http" + + "github.com/labstack/echo/v4" +) + +// AuthMiddleware checks for a valid session cookie +func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Get session ID from cookie + cookie, err := c.Cookie("session_id") + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "Unauthorized: No session found", + }) + } + + // Validate session + session, err := GetSession(cookie.Value) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{ + "error": "Unauthorized: Invalid or expired session", + }) + } + + // Store session in context for use by handlers + c.Set("session", session) + c.Set("user_id", session.UserID) + c.Set("username", session.Username) + + return next(c) + } +} + +// NoCacheMiddleware adds cache control headers to prevent browser caching +// This ensures that dynamic API responses are always fetched fresh from the server +func NoCacheMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + // Set headers to prevent caching + c.Response().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate, private") + c.Response().Header().Set("Pragma", "no-cache") + c.Response().Header().Set("Expires", "0") + + return next(c) + } +} diff --git a/internal/models.go b/internal/models.go new file mode 100644 index 0000000..7ba7cbe --- /dev/null +++ b/internal/models.go @@ -0,0 +1,112 @@ +package internal + + +import "time" + +type Message struct { + ID int64 `json:"id"` + Address string `json:"address"` + Body string `json:"body"` + Type int `json:"type"` // 1 = received, 2 = sent, 3 = draft, 4 = outbox, 5 = failed, 6 = queued + Date time.Time `json:"date"` + Read bool `json:"read"` + ThreadID int `json:"thread_id"` + Subject string `json:"subject,omitempty"` + MediaType string `json:"media_type,omitempty"` + MediaData []byte `json:"-"` + MediaBase64 string `json:"media_base64,omitempty"` + // Additional SMS fields + Protocol int `json:"protocol,omitempty"` + Status int `json:"status,omitempty"` // -1 = none, 0 = complete, 32 = pending, 64 = failed + ServiceCenter string `json:"service_center,omitempty"` + SubID int `json:"sub_id,omitempty"` + ContactName string `json:"contact_name,omitempty"` + Sender string `json:"sender,omitempty"` // Sender phone number for received messages + // Additional MMS fields + ContentType string `json:"content_type,omitempty"` // ct_t field + ReadReport int `json:"read_report,omitempty"` // rr field + ReadStatus int `json:"read_status,omitempty"` + MessageID string `json:"message_id,omitempty"` // m_id field + MessageSize int `json:"message_size,omitempty"` // m_size field + MessageType int `json:"message_type,omitempty"` // m_type field + SimSlot int `json:"sim_slot,omitempty"` + Addresses []string `json:"addresses,omitempty"` // All phone numbers in conversation (for MMS) +} + +type CallLog struct { + ID int64 `json:"id"` + Number string `json:"number"` + Duration int `json:"duration"` // in seconds + Date time.Time `json:"date"` + Type int `json:"type"` // 1 = incoming, 2 = outgoing, 3 = missed, 4 = voicemail, 5 = rejected, 6 = refused + Presentation int `json:"presentation,omitempty"` // 1 = allowed, 2 = restricted, 3 = unknown, 4 = payphone + SubscriptionID string `json:"subscription_id,omitempty"` + ContactName string `json:"contact_name,omitempty"` +} + +type Conversation struct { + Address string `json:"address"` + ContactName string `json:"contact_name,omitempty"` + Subject string `json:"subject,omitempty"` + LastMessage string `json:"last_message"` + LastDate time.Time `json:"last_date"` + MessageCount int `json:"message_count"` + Type string `json:"type"` // "sms", "mms", or "call" +} + +type ActivityItem struct { + Type string `json:"type"` // "message" or "call" + Date time.Time `json:"date"` + Address string `json:"address"` + ContactName string `json:"contact_name,omitempty"` + // Message-specific fields + Message *Message `json:"message,omitempty"` + // Call-specific fields + Call *CallLog `json:"call,omitempty"` +} + +type UploadResponse struct { + Success bool `json:"success"` + MessageCount int `json:"message_count"` + CallLogCount int `json:"call_log_count"` + Processing bool `json:"processing,omitempty"` + Error string `json:"error,omitempty"` +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` // Never send password hash to client + CreatedAt time.Time `json:"created_at"` +} + +type Session struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type AuthResponse struct { + Success bool `json:"success"` + User *User `json:"user,omitempty"` + Session *Session `json:"session,omitempty"` + Error string `json:"error,omitempty"` +} + +type ChangePasswordRequest struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + ConfirmPassword string `json:"confirm_password"` +} diff --git a/internal/parser.go b/internal/parser.go new file mode 100644 index 0000000..5428c72 --- /dev/null +++ b/internal/parser.go @@ -0,0 +1,883 @@ +package internal + + +import ( + "bytes" + "database/sql" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +type SMSBackup struct { + XMLName xml.Name `xml:"smses"` + Count int `xml:"count,attr"` + Messages []SMSEntry `xml:"sms"` + MMS []MMSEntry `xml:"mms"` + Calls []CallEntry `xml:"call"` +} + +type SMSEntry struct { + Address string `xml:"address,attr"` + Date string `xml:"date,attr"` + Type string `xml:"type,attr"` + Body string `xml:"body,attr"` + Read string `xml:"read,attr"` + ThreadID string `xml:"thread_id,attr"` + Subject string `xml:"subject,attr"` + Protocol string `xml:"protocol,attr"` + TOA string `xml:"toa,attr"` + SCTOA string `xml:"sc_toa,attr"` + ServiceCenter string `xml:"service_center,attr"` + Status string `xml:"status,attr"` + SubID string `xml:"sub_id,attr"` + ReadableDate string `xml:"readable_date,attr"` + ContactName string `xml:"contact_name,attr"` +} + +type MMSEntry struct { + Address string `xml:"address,attr"` + Date string `xml:"date,attr"` + Type string `xml:"msg_box,attr"` + Read string `xml:"read,attr"` + ThreadID string `xml:"thread_id,attr"` + Subject string `xml:"sub,attr"` + TrID string `xml:"tr_id,attr"` + ContentType string `xml:"ct_t,attr"` + ReadReport string `xml:"rr,attr"` + ReadStatus string `xml:"read_status,attr"` + MessageID string `xml:"m_id,attr"` + MessageSize string `xml:"m_size,attr"` + MessageType string `xml:"m_type,attr"` + SimSlot string `xml:"sim_slot,attr"` + ReadableDate string `xml:"readable_date,attr"` + ContactName string `xml:"contact_name,attr"` + Parts []MMSPart `xml:"parts>part"` + Addrs []MMSAddr `xml:"addrs>addr"` + Body string `xml:"body,attr"` +} + +type MMSPart struct { + Seq string `xml:"seq,attr"` + ContentType string `xml:"ct,attr"` + Name string `xml:"name,attr"` + Charset string `xml:"chset,attr"` + CL string `xml:"cl,attr"` + Text string `xml:"text,attr"` + Data string `xml:"data,attr"` +} + +type MMSAddr struct { + Address string `xml:"address,attr"` + Type string `xml:"type,attr"` + Charset string `xml:"charset,attr"` +} + +type CallEntry struct { + Number string `xml:"number,attr"` + Duration string `xml:"duration,attr"` + Date string `xml:"date,attr"` + Type string `xml:"type,attr"` + Presentation string `xml:"presentation,attr"` + SubscriptionID string `xml:"subscription_id,attr"` + ReadableDate string `xml:"readable_date,attr"` + ContactName string `xml:"contact_name,attr"` +} + +type ParseResult struct { + Messages []Message + Calls []CallLog +} + +func ParseSMSBackup(r io.Reader) (ParseResult, error) { + var backup SMSBackup + decoder := xml.NewDecoder(r) + err := decoder.Decode(&backup) + if err != nil { + return ParseResult{}, err + } + + var result ParseResult + + // Parse SMS messages + for _, sms := range backup.Messages { + msg, err := convertSMSEntry(sms) + if err != nil { + slog.Error("Error parsing SMS", "error", err) + continue + } + result.Messages = append(result.Messages, msg) + } + + // Parse MMS messages + for _, mms := range backup.MMS { + msg, err := convertMMSEntry(mms) + if err != nil { + slog.Error("Error parsing MMS", "error", err) + continue + } + result.Messages = append(result.Messages, msg) + } + + // Parse call logs + for _, call := range backup.Calls { + callLog, err := convertCallEntry(call) + if err != nil { + slog.Error("Error parsing call log", "error", err) + continue + } + result.Calls = append(result.Calls, callLog) + } + + return result, nil +} + +func convertSMSEntry(sms SMSEntry) (Message, error) { + dateMs, err := strconv.ParseInt(sms.Date, 10, 64) + if err != nil { + return Message{}, err + } + + msgType, _ := strconv.Atoi(sms.Type) + read := sms.Read == "1" + threadID, _ := strconv.Atoi(sms.ThreadID) + protocol, _ := strconv.Atoi(sms.Protocol) + status, _ := strconv.Atoi(sms.Status) + subID, _ := strconv.Atoi(sms.SubID) + + // Normalize the phone number to remove formatting differences + normalizedAddress := normalizePhoneNumber(sms.Address) + + // For SMS, the address is the single phone number + addresses := []string{} + if normalizedAddress != "" { + addresses = append(addresses, normalizedAddress) + } + + // For received SMS messages, the sender is the address + var sender string + if msgType == 1 && normalizedAddress != "" { + sender = normalizedAddress + } + + return Message{ + Address: normalizedAddress, + Body: sms.Body, + Type: msgType, + Date: time.Unix(dateMs/1000, 0), + Read: read, + ThreadID: threadID, + Subject: normalizeNullString(sms.Subject), + Protocol: protocol, + Status: status, + ServiceCenter: sms.ServiceCenter, + SubID: subID, + ContactName: sms.ContactName, + Sender: sender, + Addresses: addresses, + }, nil +} + +func convertMMSEntry(mms MMSEntry) (Message, error) { + dateMs, err := strconv.ParseInt(mms.Date, 10, 64) + if err != nil { + return Message{}, err + } + + msgType, _ := strconv.Atoi(mms.Type) + read := mms.Read == "1" + threadID, _ := strconv.Atoi(mms.ThreadID) + readReport, _ := strconv.Atoi(mms.ReadReport) + readStatus, _ := strconv.Atoi(mms.ReadStatus) + messageSize, _ := strconv.Atoi(mms.MessageSize) + messageType, _ := strconv.Atoi(mms.MessageType) + simSlot, _ := strconv.Atoi(mms.SimSlot) + + // Normalize the phone number to remove formatting differences + normalizedAddress := normalizePhoneNumber(mms.Address) + + // Extract all addresses from MMS and find the sender (type 137 = FROM) + // Include ALL addresses to keep group conversations consistent + addressMap := make(map[string]bool) + var senderAddress string + var firstAddress string + + for _, addr := range mms.Addrs { + if addr.Address != "" { + // Normalize each address to prevent duplicates due to formatting + normalizedAddr := normalizePhoneNumber(addr.Address) + if normalizedAddr != "" { + addressMap[normalizedAddr] = true + + // Remember the first address we encounter + if firstAddress == "" { + firstAddress = normalizedAddr + } + + // Type 137 (0x89) = FROM (sender in Android MMS) + // For received messages, this tells us who sent it + addrType, _ := strconv.Atoi(addr.Type) + if addrType == 137 { + senderAddress = normalizedAddr + } + } + } + } + + // If no type 137 sender was found for a received message, use the first address + // or the single address for 1-on-1 conversations + if msgType == 1 && senderAddress == "" { + if len(addressMap) == 1 && firstAddress != "" { + // 1-on-1 conversation: the single address is definitely the sender + senderAddress = firstAddress + } else if len(addressMap) > 1 && firstAddress != "" { + // Group conversation without explicit sender: use first address as best guess + senderAddress = firstAddress + } + } + + // Convert map to sorted, deduplicated slice + addresses := make([]string, 0, len(addressMap)) + for addr := range addressMap { + addresses = append(addresses, addr) + } + + // Sort addresses for consistency + sort.Strings(addresses) + + // Determine the primary address field for conversation grouping + var primaryAddress string + if len(addresses) >= 3 { + // Group MMS (3+ participants) - join all normalized addresses to create a consistent group identifier + primaryAddress = strings.Join(addresses, ",") + } else if len(addresses) > 0 { + // MMS with 1-2 addresses - use the normalized address + primaryAddress = normalizedAddress + } else { + // Fallback to normalized mms.Address if no addresses found in mms.Addrs + primaryAddress = normalizedAddress + } + + // For received messages, store the sender in the Sender field + // This allows us to display who sent each message in the UI + var sender string + if msgType == 1 && senderAddress != "" { + // Received message - store the sender address + sender = senderAddress + } + + msg := Message{ + Address: primaryAddress, + Type: msgType, + Date: time.Unix(dateMs/1000, 0), + Read: read, + ThreadID: threadID, + Subject: normalizeNullString(mms.Subject), + ContentType: mms.ContentType, + ReadReport: readReport, + ReadStatus: readStatus, + MessageID: mms.MessageID, + MessageSize: messageSize, + MessageType: messageType, + SimSlot: simSlot, + ContactName: mms.ContactName, + Sender: sender, + Addresses: addresses, + } + + // Extract body text and media from parts + var bodyText string + for _, part := range mms.Parts { + // Skip SMIL content - it's presentation metadata, not actual message content + if isSMILContentType(part.ContentType) { + continue + } + + // Check for VCF (vCard) files - these are text/* but should be treated as media attachments + if isVCardContentType(part.ContentType) && part.Data != "" { + if msg.MediaType == "" { // Only store first media item + data, err := base64.StdEncoding.DecodeString(part.Data) + if err == nil { + msg.MediaType = part.ContentType + msg.MediaData = data + } + } + continue + } + + // Check for media - media parts often have text="null" which should be ignored + if part.ContentType != "" && part.Data != "" && !isTextContentType(part.ContentType) { + // This is media content (image, video, audio, etc.) + if msg.MediaType == "" { // Only store first media item + data, err := base64.StdEncoding.DecodeString(part.Data) + if err == nil { + // Store all media as-is (including HEIC images in original format) + msg.MediaType = part.ContentType + msg.MediaData = data + } + } + } else if part.Text != "" && normalizeNullString(part.Text) != "" { + // This is actual text content (not "null") + bodyText += part.Text + " " + } + } + + if bodyText != "" { + msg.Body = strings.TrimSpace(bodyText) + } + + // Extract group name from RCS proto: tr_id if available + // Use it as the subject if the current subject is empty or starts with "proto:" + if mms.TrID != "" && strings.HasPrefix(mms.TrID, "proto:") { + groupName := extractGroupNameFromTrID(mms.TrID) + if groupName != "" { + // Only use the extracted name if subject is empty or also starts with "proto:" + if msg.Subject == "" || strings.HasPrefix(mms.Subject, "proto:") { + msg.Subject = groupName + } + } + } + + return msg, nil +} + +// normalizeNullString converts the string "null" to an empty string +func normalizeNullString(s string) string { + if strings.TrimSpace(strings.ToLower(s)) == "null" { + return "" + } + return s +} + +// isTextContentType checks if a content type is text-based +func isTextContentType(contentType string) bool { + ct := strings.ToLower(strings.TrimSpace(contentType)) + return strings.HasPrefix(ct, "text/") || + ct == "application/xml" || + ct == "application/json" +} + +// isSMILContentType checks if a content type is SMIL markup +func isSMILContentType(contentType string) bool { + ct := strings.ToLower(strings.TrimSpace(contentType)) + return ct == "application/smil" || + strings.HasPrefix(ct, "application/smil+") || + strings.Contains(ct, "smil") +} + +// isSMILMarkup checks if the body text is SMIL (Synchronized Multimedia Integration Language) markup +// which is MMS presentation metadata and should not be displayed to users +func isSMILMarkup(body string) bool { + trimmed := strings.TrimSpace(body) + return strings.HasPrefix(trimmed, " + + + + +` + +func TestSampleXMLParsing(t *testing.T) { + // Parse the XML + reader := strings.NewReader(sampleXML) + result, err := ParseSMSBackup(reader) + if err != nil { + t.Fatalf("Failed to parse XML: %v", err) + } + + // Verify we got 2 messages + if len(result.Messages) != 2 { + t.Errorf("Expected 2 messages, got %d", len(result.Messages)) + } + + // Verify first message (sent) + msg1 := result.Messages[0] + if msg1.Address != "332" { + t.Errorf("Expected address '332', got '%s'", msg1.Address) + } + if msg1.Type != 2 { + t.Errorf("Expected type 2 (sent), got %d", msg1.Type) + } + if msg1.Body != "Sample Message Sent from the phone" { + t.Errorf("Expected body 'Sample Message Sent from the phone', got '%s'", msg1.Body) + } + if msg1.Protocol != 0 { + t.Errorf("Expected protocol 0, got %d", msg1.Protocol) + } + if !msg1.Read { + t.Errorf("Expected message to be read (read=1)") + } + if msg1.Status != -1 { + t.Errorf("Expected status -1, got %d", msg1.Status) + } + // Check date: 1285799668193 milliseconds = Sep 30, 2010 8:34:28 AM + expectedDate1 := time.Unix(1285799668, 0) + if !msg1.Date.Equal(expectedDate1) { + t.Errorf("Expected date %v, got %v", expectedDate1, msg1.Date) + } + + // Verify second message (received) + msg2 := result.Messages[1] + // Phone number normalization adds +1 to 10-digit US numbers + if msg2.Address != "+14433221123" { + t.Errorf("Expected address '+14433221123', got '%s'", msg2.Address) + } + if msg2.Type != 1 { + t.Errorf("Expected type 1 (received), got %d", msg2.Type) + } + if msg2.Body != "Sample Message received by the phone" { + t.Errorf("Expected body 'Sample Message received by the phone', got '%s'", msg2.Body) + } + if msg2.Read { + t.Errorf("Expected message to be unread (read=0)") + } + // Check date: 1289643415810 milliseconds = Nov 13, 2010 9:16:55 PM + expectedDate2 := time.Unix(1289643415, 0) + if !msg2.Date.Equal(expectedDate2) { + t.Errorf("Expected date %v, got %v", expectedDate2, msg2.Date) + } + + // Verify no call logs in this sample + if len(result.Calls) != 0 { + t.Errorf("Expected 0 call logs, got %d", len(result.Calls)) + } +} + +func TestSampleXMLDatabaseIngestion(t *testing.T) { + // Create a temporary database file + tmpDB := "test_messages.db" + defer os.Remove(tmpDB) // Clean up after test + + // Initialize database + err := InitDB(tmpDB) + if err != nil { + t.Fatalf("Failed to initialize database: %v", err) + } + defer db.Close() + + // Parse the XML + reader := strings.NewReader(sampleXML) + result, err := ParseSMSBackup(reader) + if err != nil { + t.Fatalf("Failed to parse XML: %v", err) + } + + // Insert messages into database + messageCount := 0 + for i := range result.Messages { + err := InsertMessage(db, &result.Messages[i]) + if err != nil { + t.Errorf("Failed to insert message %d: %v", i, err) + continue + } + messageCount++ + + // Verify the ID was set + if result.Messages[i].ID == 0 { + t.Errorf("Message %d: ID was not set after insert", i) + } + } + + // Verify we inserted 2 messages + if messageCount != 2 { + t.Errorf("Expected to insert 2 messages, inserted %d", messageCount) + } + + // Retrieve messages from database and verify + messages, err := GetMessages(db, "332", nil, nil) + if err != nil { + t.Fatalf("Failed to retrieve messages for address '332': %v", err) + } + if len(messages) != 1 { + t.Errorf("Expected 1 message for address '332', got %d", len(messages)) + } else { + msg := messages[0] + if msg.Body != "Sample Message Sent from the phone" { + t.Errorf("Retrieved message has wrong body: '%s'", msg.Body) + } + if msg.Type != 2 { + t.Errorf("Retrieved message has wrong type: %d", msg.Type) + } + if msg.Protocol != 0 { + t.Errorf("Retrieved message has wrong protocol: %d", msg.Protocol) + } + if msg.Status != -1 { + t.Errorf("Retrieved message has wrong status: %d", msg.Status) + } + if !msg.Read { + t.Errorf("Retrieved message should be marked as read") + } + } + + // Retrieve second message + messages2, err := GetMessages(db, "+14433221123", nil, nil) + if err != nil { + t.Fatalf("Failed to retrieve messages for address '+14433221123': %v", err) + } + if len(messages2) != 1 { + t.Errorf("Expected 1 message for address '+14433221123', got %d", len(messages2)) + } else { + msg := messages2[0] + if msg.Body != "Sample Message received by the phone" { + t.Errorf("Retrieved message has wrong body: '%s'", msg.Body) + } + if msg.Type != 1 { + t.Errorf("Retrieved message has wrong type: %d", msg.Type) + } + if msg.Read { + t.Errorf("Retrieved message should be marked as unread") + } + } + + // Test GetConversations + conversations, err := GetConversations(db, nil, nil) + if err != nil { + t.Fatalf("Failed to get conversations: %v", err) + } + if len(conversations) != 2 { + t.Errorf("Expected 2 conversations, got %d", len(conversations)) + } + + // Verify conversations are sorted by date (most recent first) + // Second message (1289643415) should be first as it's more recent + if len(conversations) == 2 { + if conversations[0].Address != "+14433221123" { + t.Errorf("Expected first conversation to be '+14433221123', got '%s'", conversations[0].Address) + } + if conversations[1].Address != "332" { + t.Errorf("Expected second conversation to be '332', got '%s'", conversations[1].Address) + } + if conversations[0].MessageCount != 1 { + t.Errorf("Expected first conversation to have 1 message, got %d", conversations[0].MessageCount) + } + if conversations[0].Type != "conversation" { + t.Errorf("Expected conversation type to be 'conversation', got '%s'", conversations[0].Type) + } + } + + // Test date range functionality + startDate := time.Unix(1289000000, 0) // After first message, before second + messages3, err := GetMessages(db, "332", &startDate, nil) + if err != nil { + t.Fatalf("Failed to retrieve messages with date filter: %v", err) + } + if len(messages3) != 0 { + t.Errorf("Expected 0 messages after start date, got %d", len(messages3)) + } + + // Get date range + minDate, maxDate, err := GetDateRange(db) + if err != nil { + t.Fatalf("Failed to get date range: %v", err) + } + expectedMin := time.Unix(1285799668, 0) + expectedMax := time.Unix(1289643415, 0) + if !minDate.Equal(expectedMin) { + t.Errorf("Expected min date %v, got %v", expectedMin, minDate) + } + if !maxDate.Equal(expectedMax) { + t.Errorf("Expected max date %v, got %v", expectedMax, maxDate) + } +} + +func TestEmptyXML(t *testing.T) { + emptyXML := ` + +` + + reader := strings.NewReader(emptyXML) + result, err := ParseSMSBackup(reader) + if err != nil { + t.Fatalf("Failed to parse empty XML: %v", err) + } + + if len(result.Messages) != 0 { + t.Errorf("Expected 0 messages, got %d", len(result.Messages)) + } + if len(result.Calls) != 0 { + t.Errorf("Expected 0 calls, got %d", len(result.Calls)) + } +} + +func TestInvalidXML(t *testing.T) { + invalidXML := ` + + +` + + reader := strings.NewReader(invalidXML) + result, err := ParseSMSBackup(reader) + + // Should parse but skip invalid entries or use defaults + if err != nil { + t.Fatalf("Parser should handle invalid data gracefully: %v", err) + } + + // The message might be parsed with default values for invalid fields + if len(result.Messages) > 0 { + msg := result.Messages[0] + // Protocol "invalid" should parse as 0 + if msg.Protocol != 0 { + t.Logf("Invalid protocol parsed as: %d", msg.Protocol) + } + // Date "notanumber" should result in Unix epoch + t.Logf("Invalid date parsed as: %v", msg.Date) + } +} diff --git a/internal/utils.go b/internal/utils.go new file mode 100644 index 0000000..61ef68a --- /dev/null +++ b/internal/utils.go @@ -0,0 +1,45 @@ +package internal + +import "strings" + +// normalizePhoneNumber removes all non-numeric characters except leading + +// and standardizes US phone numbers to include the +1 country code +// This prevents duplicate conversations due to different phone number formatting +func normalizePhoneNumber(phoneNumber string) string { + if phoneNumber == "" { + return "" + } + + // Check if it starts with + + hasPlus := strings.HasPrefix(phoneNumber, "+") + + // Remove all non-numeric characters + var result strings.Builder + for _, ch := range phoneNumber { + if ch >= '0' && ch <= '9' { + result.WriteRune(ch) + } + } + + normalized := result.String() + if normalized == "" { + return "" + } + + // Standardize US phone numbers + if !hasPlus { + // 10 digits without country code - add +1 (US number) + if len(normalized) == 10 { + return "+1" + normalized + } + // 11 digits starting with 1 - add + (US number with 1 prefix) + if len(normalized) == 11 && normalized[0] == '1' { + return "+" + normalized + } + // Other lengths without + - keep as is (might be partial/invalid) + return normalized + } + + // Already has +, keep it + return "+" + normalized +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..304c3eb --- /dev/null +++ b/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "log/slog" + "net/http" + _ "net/http/pprof" + "os" + "time" + + "github.com/lowcarbdev/sbv/internal" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +var logger *slog.Logger + +func main() { + // Initialize slog logger + logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + // Initialize authentication database + dbPathPrefix := os.Getenv("DB_PATH_PREFIX") + if dbPathPrefix == "" { + dbPathPrefix = "." + } + authDBPath := dbPathPrefix + "/sbv.db" + + err := internal.InitAuthDB(authDBPath) + if err != nil { + logger.Error("Failed to initialize authentication database", "error", err) + os.Exit(1) + } + logger.Info("Authentication database initialized", "path", authDBPath) + + // Create Echo instance + e := echo.New() + + // Middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + + // Use custom CORS middleware that properly handles credentials + e.Use(internal.CustomCORSMiddleware()) + + // Configure timeouts for large file uploads + e.Server.ReadTimeout = 30 * time.Minute + e.Server.WriteTimeout = 30 * time.Minute + e.Server.ReadHeaderTimeout = 1 * time.Minute + e.Server.IdleTimeout = 2 * time.Minute + e.Server.MaxHeaderBytes = 1 << 20 // 1 MB max header size + + // Public routes (no authentication required) + // Apply NoCacheMiddleware to prevent browser caching of auth responses + e.POST("/api/auth/register", internal.HandleRegister, internal.NoCacheMiddleware) + e.POST("/api/auth/login", internal.HandleLogin, internal.NoCacheMiddleware) + e.POST("/api/auth/logout", internal.HandleLogout, internal.NoCacheMiddleware) + + // Protected routes (authentication required) + protected := e.Group("/api") + protected.Use(internal.AuthMiddleware) + protected.Use(internal.NoCacheMiddleware) // Prevent browser caching of API responses + + protected.GET("/auth/me", internal.HandleMe) + protected.POST("/auth/change-password", internal.HandleChangePassword) + protected.POST("/upload", internal.HandleUpload) + protected.GET("/conversations", internal.HandleConversations) + protected.GET("/messages", internal.HandleMessages) + protected.GET("/activity", internal.HandleActivity) + protected.GET("/daterange", internal.HandleDateRange) + protected.GET("/progress", internal.HandleProgress) + protected.GET("/media", internal.HandleMedia) + protected.GET("/search", internal.HandleSearch) + + // Health check + e.GET("/api/health", func(c echo.Context) error { + return c.String(http.StatusOK, "OK") + }) + + // Serve static files from frontend/dist if it exists (for production/Docker) + if _, err := os.Stat("./frontend/dist"); err == nil { + // Serve static assets (JS, CSS, images, etc.) + e.Static("/assets", "./frontend/dist/assets") + e.File("/favicon.ico", "./frontend/dist/favicon.ico") + e.File("/favicon.svg", "./frontend/dist/favicon.svg") + e.File("/apple-touch-icon.png", "./frontend/dist/apple-touch-icon.png") + e.File("/favicon-96x96.png", "./frontend/dist/favicon-96x96.png") + e.File("/web-app-manifest-192x192.png", "./frontend/dist/web-app-manifest-192x192.png") + e.File("/web-app-manifest-512x512.png", "./frontend/dist/web-app-manifest-512x512.png") + e.File("/site.webmanifest", "./frontend/dist/site.webmanifest") + + // SPA fallback - serve index.html for all non-API routes + // This must be last so it doesn't interfere with API routes + e.GET("/*", func(c echo.Context) error { + return c.File("./frontend/dist/index.html") + }) + + logger.Info("Serving static files from ./frontend/dist with SPA routing support") + } + + // Start pprof server in a separate goroutine for profiling + go func() { + port := os.Getenv("PORT") + if port == "" { + port = "8081" + } + pprofPort := "6060" + logger.Info("Memory profiling available", "url", "http://localhost:"+pprofPort+"/debug/pprof/") + if err := http.ListenAndServe(":"+pprofPort, nil); err != nil { + logger.Error("pprof server failed", "error", err) + } + }() + + port := os.Getenv("PORT") + if port == "" { + port = "8081" + } + + // Create HTTP server with longer timeouts for large file uploads + server := &http.Server{ + Addr: ":" + port, + ReadTimeout: 30 * time.Minute, // Allow 30 minutes for reading large uploads + WriteTimeout: 30 * time.Minute, // Allow 30 minutes for writing responses + ReadHeaderTimeout: 1 * time.Minute, // Header read timeout + IdleTimeout: 2 * time.Minute, // Idle connection timeout + MaxHeaderBytes: 1 << 20, // 1 MB max header size + } + + logger.Info("Server starting", "port", port) + logger.Info("Upload timeout set to 30 minutes for large backup files") + + e.Server = server + // Start server + if err := e.Start(":" + port); err != nil && err != http.ErrServerClosed { + logger.Error("Server failed to start", "error", err) + os.Exit(1) + } +}