diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8379e86 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,71 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage +.nyc_output +__tests__ +e2e +*.test.ts +*.test.tsx +jest.config.js +jest.integration.config.js +playwright.config.ts + +# Next.js +.next +out +.turbopack + +# Production +build +dist + +# Misc +.DS_Store +*.pem +.env*.local +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.idea +.vscode +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore +.gitattributes + +# CI/CD +.github +.gitlab-ci.yml +.travis.yml + +# Documentation +README.md +CLAUDE.md +docs +*.md + +# Docker +Dockerfile +docker-compose.yml +.dockerignore \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..e84a56d --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "extends": [ + "next/core-web-vitals" + ], + "rules": { + "@typescript-eslint/no-unused-vars": ["warn", { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + }], + "@typescript-eslint/no-explicit-any": "warn", + "react/no-unescaped-entities": "off" + } +} \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..24e5e20 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,76 @@ +name: Lint, Build and Push Docker Image + +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review + push: + branches: + - main + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + lint-and-test: + name: Lint and Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm test + + - name: Run integration tests + run: npm run test:integration + + build-and-push: + name: Build and Push AMD64/ARM64 Image + needs: lint-and-test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1284b9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + NODE_VERSION: '20' + +jobs: + lint-and-test: + name: Lint and Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm test -- --passWithNoTests + + - name: Run integration tests + run: npm run test:integration -- --passWithNoTests + env: + CI: true + + build-and-push: + name: Build and Push Docker Image + needs: lint-and-test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + security-scan: + name: Security Scan + needs: build-and-push + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + security-events: write + steps: + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-results.sarif' \ No newline at end of file diff --git a/API_ROUTES.md b/API_ROUTES.md new file mode 100644 index 0000000..3279186 --- /dev/null +++ b/API_ROUTES.md @@ -0,0 +1,180 @@ +# API Routes Documentation + +## Base URL +- Development: `http://localhost:3000/api` +- Production: `https://your-domain.com/api` + +## Health Check +### GET /health +Check the health status of the application and its services. + +**Response:** +```json +{ + "success": true, + "data": { + "status": "healthy", + "timestamp": "2024-01-15T10:00:00Z", + "services": { + "database": true, + "minio": true + } + } +} +``` + +## Authentication + +### POST /v1/auth/login +Authenticate a user and create a session. + +**Request Body:** +```json +{ + "email": "user@example.com", + "password": "password123" +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "id": "1", + "email": "user@example.com", + "name": "John Doe", + "role": "user" + }, + "message": "Login successful" +} +``` + +### POST /v1/auth/logout +End the current user session. + +**Response:** +```json +{ + "success": true, + "message": "Logged out successfully" +} +``` + +### GET /v1/auth/me +Get the current authenticated user's information. + +**Response:** +```json +{ + "success": true, + "data": { + "id": "1", + "email": "user@example.com", + "name": "John Doe", + "role": "user" + } +} +``` + +## Chains + +### GET /v1/chains +Get a list of all supported blockchain networks. + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": "cosmos-hub", + "name": "Cosmos Hub", + "network": "cosmoshub-4", + "description": "The Cosmos Hub is the first of thousands of interconnected blockchains.", + "logoUrl": "/chains/cosmos.png" + } + ] +} +``` + +### GET /v1/chains/[chainId] +Get details for a specific chain. + +**Response:** +```json +{ + "success": true, + "data": { + "id": "cosmos-hub", + "name": "Cosmos Hub", + "network": "cosmoshub-4", + "description": "The Cosmos Hub is the first of thousands of interconnected blockchains.", + "logoUrl": "/chains/cosmos.png" + } +} +``` + +### GET /v1/chains/[chainId]/snapshots +Get available snapshots for a specific chain. + +**Response:** +```json +{ + "success": true, + "data": [ + { + "id": "cosmos-snapshot-1", + "chainId": "cosmos-hub", + "height": 19234567, + "size": 483183820800, + "fileName": "cosmoshub-4-19234567.tar.lz4", + "createdAt": "2024-01-15T00:00:00Z", + "updatedAt": "2024-01-15T00:00:00Z", + "type": "pruned", + "compressionType": "lz4" + } + ] +} +``` + +### POST /v1/chains/[chainId]/download +Generate a presigned download URL for a snapshot. + +**Request Body:** +```json +{ + "snapshotId": "cosmos-snapshot-1", + "email": "user@example.com" // optional +} +``` + +**Response:** +```json +{ + "success": true, + "data": { + "downloadUrl": "https://minio.example.com/snapshots/cosmoshub-4-19234567.tar.lz4?..." + }, + "message": "Download URL generated successfully" +} +``` + +## Error Responses + +All error responses follow this format: +```json +{ + "success": false, + "error": "Error type", + "message": "Detailed error message" +} +``` + +Common HTTP status codes: +- 200: Success +- 400: Bad Request +- 401: Unauthorized +- 403: Forbidden +- 404: Not Found +- 500: Internal Server Error \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b3e5edd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,219 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Blockchain Snapshot Service** - A production-grade Next.js application that provides bandwidth-managed blockchain snapshot hosting with tiered user access (free: 50MB/s shared, premium: 250MB/s shared). Uses MinIO for object storage and implements comprehensive monitoring, security, and user management. + +## Key Architecture Components + +1. **Next.js 15** - Full-stack application with App Router for both UI and API +2. **MinIO Object Storage** - S3-compatible storage for snapshot files +3. **JWT Authentication** - Simple auth system for premium tier access +4. **Bandwidth Management** - Tiered speed limits enforced at MinIO level +5. **Prometheus/Grafana** - Monitoring and observability + +## Development Commands + +```bash +# Development +npm run dev # Start development server with Turbopack +npm run build # Build for production +npm run start # Start production server +npm run lint # Run ESLint + +# Testing (to be implemented) +npm run test # Run unit tests +npm run test:e2e # Run E2E tests with Playwright +npm run test:load # Run load tests with k6 +``` + +## Project Structure + +``` +app/ +├── api/v1/ # API routes +│ ├── chains/ # Chain management endpoints +│ │ └── [chainId]/ # Dynamic chain routes +│ │ ├── snapshots/ # List snapshots +│ │ └── download/ # Generate download URLs +│ ├── auth/ # Authentication endpoints +│ │ ├── login/ # JWT login +│ │ └── logout/ # Clear session +│ └── health/ # Health check +├── chains/ # UI pages +│ └── [chainId]/ # Chain-specific snapshot listing +├── login/ # Login page +├── layout.tsx # Root layout with auth context +└── page.tsx # Homepage + +lib/ +├── auth/ # Authentication utilities +│ ├── session.ts # Session management +│ └── middleware.ts # Auth middleware +├── minio/ # MinIO integration +│ ├── client.ts # MinIO client setup +│ └── operations.ts # MinIO operations +├── bandwidth/ # Bandwidth management +│ └── manager.ts # Dynamic bandwidth calculation +├── config/ # Configuration +└── types/ # TypeScript types + +components/ +├── auth/ # Auth components +├── snapshots/ # Snapshot UI components +│ ├── SnapshotList.tsx +│ └── DownloadButton.tsx +└── common/ # Shared components +``` + +## Implementation Order (From GitHub Issues) + +### Phase 1: Backend API (Priority) +1. **API Routes** - Implement all `/api/v1/*` endpoints +2. **MinIO Integration** - Connect to MinIO for object operations +3. **Authentication** - JWT-based auth system +4. **URL Generation** - Pre-signed URLs with security + +### Phase 2: Frontend UI +5. **Snapshot Browsing** - List chains and snapshots +6. **Login/Auth UI** - User authentication interface +7. **Download Experience** - Bandwidth indicators and UX + +### Phase 3: Infrastructure +8. **Monitoring** - Prometheus metrics and Grafana dashboards +9. **CI/CD** - GitHub Actions pipeline +10. **Testing** - Comprehensive test suite +11. **Documentation** - User and ops docs + +## Critical Implementation Details + +### MinIO Configuration +- Endpoint: `http://minio.apps.svc.cluster.local:9000` (K8s internal) +- Bucket: `snapshots` +- Pre-signed URLs: 5-minute expiration, IP-restricted + +### Authentication Flow +- Single premium user (credentials in env vars) +- JWT tokens in httpOnly cookies +- 7-day session duration +- Middleware validates on protected routes + +### Bandwidth Management +- Free tier: 50MB/s shared among all free users +- Premium tier: 250MB/s shared among all premium users +- Total cap: 500MB/s +- Enforced at MinIO level via metadata + +### API Response Format +```typescript +// Success response +{ + data: any, + success: true +} + +// Error response +{ + error: string, + status: number +} +``` + +### Environment Variables +```bash +# MinIO +MINIO_ENDPOINT=http://minio.apps.svc.cluster.local:9000 +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= + +# Auth +PREMIUM_USERNAME=premium_user +PREMIUM_PASSWORD_HASH= +JWT_SECRET= + +# Config +BANDWIDTH_FREE_TOTAL=50 +BANDWIDTH_PREMIUM_TOTAL=250 +AUTH_SESSION_DURATION=7d +DOWNLOAD_URL_EXPIRY=5m +``` + +## Key Features to Implement + +### Core Features +1. **List all chains with snapshots** - Homepage showing available chains +2. **Browse chain snapshots** - Detailed view with metadata +3. **Generate download URLs** - Secure, time-limited URLs +4. **User authentication** - Login for premium tier access +5. **Bandwidth enforcement** - Tier-based speed limits + +### Security Features +- JWT authentication with secure cookies +- Pre-signed URLs with IP restriction +- Rate limiting (10 downloads/minute) +- CORS configuration +- Input validation on all endpoints + +### Monitoring +- API request metrics +- Bandwidth usage tracking +- Download analytics +- Error rate monitoring +- Storage usage alerts + +## Development Guidelines + +### API Development +- Use Next.js Route Handlers (App Router) +- Implement proper error handling +- Return consistent response formats +- Add request validation +- Keep response times <200ms + +### Frontend Development +- Use TypeScript for type safety +- Implement loading and error states +- Make responsive for all devices +- Follow accessibility standards +- Use Tailwind CSS for styling + +### Testing Requirements +- Unit tests for API logic (>80% coverage) +- Integration tests with MinIO +- E2E tests for critical flows +- Load tests for bandwidth limits +- Security tests for auth system + +## Common Tasks + +### Adding a New Chain +1. Upload snapshot files to MinIO bucket +2. Create metadata JSON file +3. Chain will appear automatically in API/UI + +### Testing Bandwidth Limits +```bash +# Test free tier (should be ~50MB/s) +curl -O [generated-url] + +# Test premium tier (should be ~250MB/s) +curl -H "Cookie: auth-token=[jwt]" -O [generated-url] +``` + +### Debugging MinIO Connection +```bash +# Check MinIO health +curl http://minio.apps.svc.cluster.local:9000/minio/health/live + +# List buckets (with mc CLI) +mc ls myminio/ +``` + +## Important Notes + +1. **No Polkachu API** - This replaces the prototype. All data comes from MinIO +2. **BryanLabs Style** - Maintain professional design aesthetic +3. **Performance First** - Optimize for speed and reliability +4. **Security Critical** - Properly implement auth and access controls \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94219d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Build stage +FROM node:20-alpine AS builder + +# Add dependencies for native modules +RUN apk add --no-cache libc6-compat + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --legacy-peer-deps + +# Copy source code +COPY . . + +# Build the application +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +# Add non-root user +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nextjs -u 1001 + +# Copy built application +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +# Set permissions +RUN chown -R nextjs:nodejs /app + +USER nextjs + +# Expose port +EXPOSE 3000 + +# Environment variables +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); })" + +# Start the application +CMD ["node", "server.js"] \ No newline at end of file diff --git a/README.md b/README.md index 3d591e7..5a928e1 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,405 @@ -# Blockchain Snapshots - Next.js App +# Blockchain Snapshots Service + +A production-grade blockchain snapshot hosting service providing reliable, bandwidth-managed access to blockchain snapshots with tiered user system. Built with Next.js, MinIO, and deployed on Kubernetes. + +## 🚀 Overview + +The Blockchain Snapshots Service provides high-speed access to blockchain node snapshots for the Cosmos ecosystem. It features: + +- **Tiered Access**: Free tier (50MB/s shared) and Premium tier (250MB/s shared) +- **Resume Support**: Interrupted downloads can be resumed +- **Real-time Monitoring**: Prometheus metrics and Grafana dashboards +- **High Availability**: Redundant deployments with automatic failover +- **Security**: JWT authentication, pre-signed URLs, and IP restrictions + +## 📋 Table of Contents + +- [Key Features](#-key-features) +- [Tech Stack](#-tech-stack) +- [Architecture](#-architecture) +- [Getting Started](#-getting-started) +- [Development](#-development) +- [API Reference](#-api-reference) +- [Testing](#-testing) +- [Deployment](#-deployment) +- [Monitoring](#-monitoring) +- [Contributing](#-contributing) +- [License](#-license) + +## ✨ Key Features + +### Core Functionality +- **Multiple Chain Support**: Host snapshots for 30+ Cosmos chains +- **Bandwidth Management**: Dynamic per-connection bandwidth allocation +- **Download Resume**: Support for interrupted download resumption +- **Real-time Updates**: Daily snapshot updates with automated sync +- **Compression Options**: LZ4 compressed snapshots for faster downloads + +### User Experience +- **Instant Access**: No registration required for free tier +- **Premium Tier**: 5x faster downloads for authenticated users +- **Search & Filter**: Find snapshots by chain name or network +- **Download Progress**: Real-time download statistics +- **Mobile Responsive**: Optimized for all device sizes + +### Technical Features +- **Pre-signed URLs**: Secure, time-limited download links +- **Rate Limiting**: Prevent abuse with configurable limits +- **Health Checks**: Automated monitoring and alerting +- **Metrics Export**: Prometheus-compatible metrics +- **GitOps Ready**: Kubernetes manifests for easy deployment + +## 🛠️ Tech Stack + +### Frontend +- **Next.js 15**: React framework with App Router +- **TypeScript 5**: Type-safe development +- **Tailwind CSS 4**: Utility-first styling +- **React 19**: Latest React features +- **Inter Font**: Professional typography + +### Backend +- **Next.js API Routes**: Full-stack capabilities +- **MinIO**: S3-compatible object storage +- **JWT**: Secure authentication +- **Prometheus**: Metrics collection +- **Node.js 20**: Runtime environment + +### Infrastructure +- **Kubernetes**: Container orchestration +- **TopoLVM**: Dynamic volume provisioning +- **HAProxy**: Load balancing +- **Grafana**: Metrics visualization +- **GitHub Actions**: CI/CD pipeline + +## 🏗️ Architecture + +### High-Level Overview +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Browser │────▶│ Next.js │────▶│ MinIO │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ │ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ Prometheus │ │ TopoLVM │ + └─────────────┘ └─────────────┘ +``` -A professional blockchain snapshots application built with Next.js 15, following the BryanLabs design style and branding. +### Component Interaction +1. User browses available snapshots via Next.js frontend +2. Authentication checked for tier determination +3. Pre-signed URL generated with bandwidth metadata +4. Direct download from MinIO with rate limiting +5. Metrics collected for monitoring -## 🚀 Tech Stack +## 🚀 Getting Started -- **Next.js 15** with App Router -- **React 19** -- **TypeScript** for type safety -- **Tailwind CSS 4** for styling -- **Inter Font** for typography (matching BryanLabs style) +### Prerequisites +- Node.js 20.x or higher +- npm or yarn +- Docker (for MinIO development) +- Kubernetes cluster (for production) -## 🎨 Features +### Quick Start with Docker Compose -- **Professional Design** matching BryanLabs aesthetic -- **Fully Responsive** layout that works on all devices -- **Interactive Search Bar** for finding blockchain chains -- **Statistics Display** showing key metrics (30+ Chains, Daily Updates, 99.9% Uptime) -- **Modern Typography** using Inter font family -- **Smooth Animations** and hover effects -- **SEO Optimized** with proper metadata -- **Accessibility** features built-in +1. **Clone the repository** + ```bash + git clone https://github.com/bryanlabs/snapshots.git + cd snapshots + ``` -## 🏗️ Project Structure +2. **Create mock data** + ```bash + ./scripts/setup-mock-data.sh + ``` -``` -├── app/ -│ ├── layout.tsx # Root layout with Inter font and metadata -│ ├── page.tsx # Hero section implementation -│ ├── globals.css # Global styles with Tailwind and custom properties -│ └── favicon.ico # Site favicon -├── public/ -│ ├── bryanlabs-logo.svg # BryanLabs logo -│ └── bryanlabs_banner.png # Banner image -├── package.json # Dependencies and scripts -├── tsconfig.json # TypeScript configuration -├── tailwind.config.* # Tailwind CSS configuration -└── next.config.ts # Next.js configuration -``` +3. **Start all services** + ```bash + docker-compose up -d + ``` -## 🎯 Design Elements +4. **Access the application** + - Application: [http://localhost:3000](http://localhost:3000) + - MinIO Console: [http://localhost:9001](http://localhost:9001) (admin/minioadmin) -### Typography +5. **Test premium login** + - Username: `premium_user` + - Password: `premium123` -- **Primary Font**: Inter (Google Fonts) -- **Hero Title**: 4xl-7xl responsive, weight 800 -- **Subtitle**: xl-2xl responsive, weight 400 -- **Features**: Clean bullet-separated list +### Quick Start (Development) -### Color Scheme +1. **Clone the repository** + ```bash + git clone https://github.com/bryanlabs/snapshots.git + cd snapshots + ``` -- **Background**: Gradient from slate-50 to slate-100 -- **Text**: Professional grays (#1a1a1a primary, #4b5563 secondary) -- **Accent**: Blue (#3b82f6) for interactive elements -- **Borders**: Light gray (#e5e7eb) +2. **Install dependencies** + ```bash + npm install + ``` -### Layout +3. **Set up environment variables** + ```bash + cp .env.example .env.local + # Edit .env.local with your configuration + ``` -- **Centered Content** with responsive max-widths -- **Grid-Based Statistics** (3 columns desktop, 1 column mobile) -- **Sticky Navigation** with backdrop blur effect -- **Prominent Search** with icon and focus states +4. **Start MinIO (Docker)** + ```bash + docker run -p 9000:9000 -p 9001:9001 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio server /data --console-address ":9001" + ``` -## 🧩 Key Components +5. **Run development server** + ```bash + npm run dev + ``` -### Navigation +6. **Open browser** + Navigate to [http://localhost:3000](http://localhost:3000) -- Sticky header with backdrop blur -- BryanLabs logo and brand name -- Clean navigation links that hide on mobile -- Smooth hover transitions +## 💻 Development -### Hero Section +### Project Structure +``` +snapshots/ +├── app/ # Next.js app directory +│ ├── api/ # API routes +│ ├── chains/ # Chain pages +│ ├── login/ # Auth pages +│ └── page.tsx # Homepage +├── components/ # React components +├── lib/ # Utilities and helpers +├── hooks/ # Custom React hooks +├── __tests__/ # Test files +├── docs/ # Documentation +└── public/ # Static assets +``` -- Bold "Blockchain Snapshots" title -- Descriptive subtitle about Cosmos ecosystem -- Feature highlights: "Updated daily • Pruned options available • Global CDN delivery" -- Interactive search bar with search icon -- Statistics grid showing key metrics +### Environment Variables +```bash +# MinIO Configuration +MINIO_ENDPOINT=http://localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin -### Responsive Design +# Authentication +JWT_SECRET=your-secret-key +PREMIUM_USERNAME=premium_user +PREMIUM_PASSWORD_HASH=$2a$10$... -- **Desktop**: Full navigation, large typography, 3-column stats -- **Tablet**: Hidden navigation menu, medium typography -- **Mobile**: Single column layout, optimized touch targets +# Bandwidth Limits (MB/s) +BANDWIDTH_FREE_TOTAL=50 +BANDWIDTH_PREMIUM_TOTAL=250 -## 🚀 Getting Started +# API Configuration +NEXT_PUBLIC_API_URL=http://localhost:3000 +``` -1. **Install Dependencies** +### Development Commands +```bash +# Start development server +npm run dev - ```bash - npm install - ``` +# Run tests +npm test -2. **Run Development Server** +# Run tests in watch mode +npm run test:watch - ```bash - npm run dev - ``` +# Run e2e tests +npm run test:e2e -3. **Open Browser** - Navigate to [http://localhost:3000](http://localhost:3000) +# Build for production +npm run build -## 📝 Available Scripts +# Start production server +npm start -- `npm run dev` - Start development server with Turbopack -- `npm run build` - Build for production -- `npm run start` - Start production server -- `npm run lint` - Run ESLint +# Run linting +npm run lint -## 🛠️ Customization +# Format code +npm run format +``` + +## 📚 API Reference -### Update Colors +See [API Routes Documentation](./API_ROUTES.md) for detailed endpoint information. -Edit the CSS custom properties in `app/globals.css`: +### Quick Reference +- `GET /api/health` - Health check +- `GET /api/v1/chains` - List all chains +- `GET /api/v1/chains/[chainId]/snapshots` - List snapshots +- `POST /api/v1/chains/[chainId]/download` - Generate download URL +- `POST /api/v1/auth/login` - User authentication +- `GET /api/v1/auth/me` - Current user info -```css -:root { - --foreground: #1a1a1a; /* Primary text */ - --muted-foreground: #4b5563; /* Secondary text */ - --accent: #3b82f6; /* Interactive elements */ - --border: #e5e7eb; /* Borders */ -} +## 🧪 Testing + +### Test Structure +``` +__tests__/ +├── api/ # API route tests +├── components/ # Component tests +├── integration/ # Integration tests +└── e2e/ # End-to-end tests ``` -### Update Content +### Running Tests +```bash +# Unit tests +npm test -Modify the hero section in `app/page.tsx`: +# Integration tests +npm run test:integration -- Change statistics in the `HeroStats` component -- Update feature bullets in the hero section -- Modify navigation links in the `Navigation` component +# E2E tests (requires running app) +npm run test:e2e -### Update Branding +# Test coverage +npm run test:coverage +``` -- Replace `public/bryanlabs-logo.svg` with your logo -- Update metadata in `app/layout.tsx` -- Modify the brand name in the navigation +### Writing Tests +```typescript +// Example API test +describe('Download API', () => { + it('should generate URL for free tier', async () => { + const response = await request(app) + .post('/api/v1/chains/cosmos-hub/download') + .send({ filename: 'latest.tar.lz4' }) + + expect(response.status).toBe(200) + expect(response.body.tier).toBe('free') + }) +}) +``` -## 🌐 SEO & Metadata +## 🚢 Deployment -The app includes comprehensive SEO optimization: +### Docker Deployment -- **Title**: "Blockchain Snapshots - BryanLabs" -- **Description**: Optimized for search engines -- **Keywords**: blockchain, snapshots, cosmos, cosmos-sdk, devops -- **Open Graph**: Social media sharing optimization -- **Twitter Cards**: Twitter sharing optimization +1. **Build the image** + ```bash + docker build -t snapshots-app . + ``` -## 📱 Browser Support +2. **Run with Docker Compose** + ```bash + docker-compose up -d + ``` -- **Chrome/Edge**: Full support with latest features -- **Firefox**: Full support -- **Safari**: Full support with webkit optimizations -- **Mobile**: Optimized responsive experience +3. **View logs** + ```bash + docker-compose logs -f app + ``` -## 🎨 BryanLabs Style Guide +4. **Stop services** + ```bash + docker-compose down + ``` + +### Docker Hub / GitHub Container Registry + +The CI/CD pipeline automatically builds and pushes images to GitHub Container Registry: -This implementation follows BryanLabs' design principles: +```bash +# Pull the latest image +docker pull ghcr.io/bryanlabs/snapshots:latest -- **Professional & Clean**: Minimal, focused design -- **Technical Authority**: Bold typography and clear messaging -- **Trust & Reliability**: Statistics and feature highlights -- **Modern & Responsive**: Works perfectly on all devices -- **Performance Focused**: Optimized with Next.js 15 and Tailwind CSS +# Run the container +docker run -p 3000:3000 \ + --env-file .env.local \ + ghcr.io/bryanlabs/snapshots:latest +``` + +### Kubernetes Deployment + +1. **Create namespace** + ```bash + kubectl create namespace apps + ``` + +2. **Apply configurations** + ```bash + kubectl apply -f k8s/configmap.yaml + kubectl apply -f k8s/secrets.yaml + kubectl apply -f k8s/deployment.yaml + kubectl apply -f k8s/service.yaml + ``` + +3. **Verify deployment** + ```bash + kubectl get pods -n apps + kubectl get svc -n apps + ``` -The hero section captures BryanLabs' **professional, technical expertise** while providing a **clean, modern interface** for blockchain snapshot services. +### CI/CD Pipeline +The project uses GitHub Actions for automated deployment: +- Tests run on every push +- Docker images built and pushed to registry +- Kubernetes manifests updated automatically +- Rollback capability for failed deployments + +## 📊 Monitoring + +### Metrics Collection +The service exports Prometheus metrics: +- Request counts and latencies +- Download statistics by tier +- Bandwidth usage metrics +- Error rates and types + +### Grafana Dashboards +Pre-built dashboards available in `docs/grafana/`: +- Service Overview +- Bandwidth Usage +- User Analytics +- Error Tracking + +### Alerts +Configured alerts for: +- High error rates +- Bandwidth limit exceeded +- Storage capacity low +- Service unavailability + +## 🤝 Contributing + +We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details. + +### Development Process +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Submit a pull request + +### Code Style +- Follow TypeScript best practices +- Use ESLint and Prettier +- Write meaningful commit messages +- Add JSDoc comments for public APIs + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. + +## 🙏 Acknowledgments + +- BryanLabs team for infrastructure support +- Polkachu for snapshot data integration +- Cosmos ecosystem for blockchain technology +- Open source contributors + +## 📞 Support + +- **Documentation**: [docs/](./docs/) +- **Issues**: [GitHub Issues](https://github.com/bryanlabs/snapshots/issues) +- **Discord**: [BryanLabs Discord](https://discord.gg/bryanlabs) +- **Email**: support@bryanlabs.net diff --git a/__tests__/README.md b/__tests__/README.md new file mode 100644 index 0000000..100c8e2 --- /dev/null +++ b/__tests__/README.md @@ -0,0 +1,190 @@ +# BryanLabs Snapshots Test Suite + +This directory contains the comprehensive test suite for the BryanLabs Snapshots service. + +## Test Structure + +``` +__tests__/ +├── api/ # API route tests +│ ├── auth.test.ts # Authentication endpoints +│ ├── chains.test.ts # Chain listing endpoint +│ ├── chainById.test.ts # Individual chain endpoint +│ ├── download.test.ts # Download URL generation +│ ├── health.test.ts # Health check endpoint +│ └── snapshots.test.ts # Snapshot listing +├── lib/ # Utility function tests +│ ├── auth/ +│ │ └── session.test.ts # Session management +│ ├── bandwidth/ +│ │ └── manager.test.ts # Bandwidth tracking +│ └── middleware/ +│ └── rateLimiter.test.ts # Rate limiting +├── components/ # React component tests +│ ├── ChainList.test.tsx # Chain listing component +│ ├── DownloadButton.test.tsx # Download functionality +│ └── LoginForm.test.tsx # Login form component +└── integration/ # Integration tests + ├── auth-flow.test.ts # Complete auth flow + └── download-flow.test.ts # Complete download flow +``` + +## Running Tests + +### Unit Tests +```bash +# Run all unit tests +npm test + +# Run tests in watch mode +npm run test:watch + +# Run tests with coverage +npm run test:coverage +``` + +### Integration Tests +```bash +# Run integration tests +npm run test:integration +``` + +### E2E Tests +```bash +# Run E2E tests +npm run test:e2e + +# Run E2E tests with UI +npm run test:e2e:ui +``` + +### Run All Tests +```bash +# Run unit and integration tests +npm run test:all +``` + +## Test Coverage + +The test suite aims for: +- 70% minimum code coverage across all metrics +- 100% coverage for critical paths (auth, downloads, bandwidth) +- All API endpoints tested +- All user-facing components tested +- Integration tests for complete workflows + +## Mocking Strategy + +### API Routes +- MinIO client is mocked to avoid external dependencies +- Session management is mocked using jest mocks +- Monitoring metrics are mocked to prevent side effects + +### Components +- Next.js navigation is mocked +- Auth context is mocked for isolated component testing +- External API calls are mocked + +### Integration Tests +- End-to-end flows are tested with minimal mocking +- Only external services (MinIO, metrics) are mocked + +## Writing New Tests + +### Unit Test Template +```typescript +import { functionToTest } from '@/path/to/module'; + +describe('Module Name', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Setup + }); + + it('should do something', () => { + // Arrange + const input = 'test'; + + // Act + const result = functionToTest(input); + + // Assert + expect(result).toBe('expected'); + }); +}); +``` + +### Component Test Template +```typescript +import { render, screen } from '@testing-library/react'; +import { ComponentName } from '@/components/ComponentName'; + +describe('ComponentName', () => { + it('should render correctly', () => { + render(); + + expect(screen.getByText('Expected Text')).toBeInTheDocument(); + }); +}); +``` + +## CI/CD Integration + +Tests are automatically run in CI/CD pipeline: +1. Unit tests run on every push +2. Integration tests run on pull requests +3. E2E tests run before deployment +4. Coverage reports are generated and tracked + +## Debugging Tests + +### Debug Unit Tests +```bash +# Run specific test file +npm test -- __tests__/api/auth.test.ts + +# Run tests matching pattern +npm test -- --testNamePattern="should login" + +# Debug in VS Code +# Add breakpoint and run "Jest: Debug" from command palette +``` + +### Debug E2E Tests +```bash +# Run E2E tests with headed browser +npx playwright test --headed + +# Debug specific test +npx playwright test auth.spec.ts --debug +``` + +## Best Practices + +1. **Isolation**: Each test should be independent +2. **Clarity**: Test names should clearly describe what they test +3. **Completeness**: Test both happy paths and error cases +4. **Performance**: Mock external dependencies +5. **Maintenance**: Update tests when code changes + +## Common Issues + +### Test Fails with "Cannot find module" +- Check import paths use `@/` alias +- Ensure TypeScript paths are configured + +### Component Test Fails with Router Error +- Ensure Next.js router is properly mocked +- Check jest.setup.js includes router mocks + +### E2E Test Timeout +- Increase timeout in playwright.config.ts +- Check if dev server is running +- Verify selectors are correct + +## Resources + +- [Jest Documentation](https://jestjs.io/docs/getting-started) +- [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) +- [Playwright Documentation](https://playwright.dev/docs/intro) +- [Next.js Testing](https://nextjs.org/docs/app/building-your-application/testing) \ No newline at end of file diff --git a/__tests__/api/auth.test.ts b/__tests__/api/auth.test.ts new file mode 100644 index 0000000..22a95a6 --- /dev/null +++ b/__tests__/api/auth.test.ts @@ -0,0 +1,268 @@ +import { NextRequest } from 'next/server'; +import { POST as loginPOST } from '@/app/api/v1/auth/login/route'; +import { POST as logoutPOST } from '@/app/api/v1/auth/logout/route'; +import { GET as meGET } from '@/app/api/v1/auth/me/route'; +import * as authSession from '@/lib/auth/session'; +import * as metrics from '@/lib/monitoring/metrics'; +import * as logger from '@/lib/middleware/logger'; +import bcrypt from 'bcryptjs'; + +// Mock dependencies +jest.mock('@/lib/auth/session'); +jest.mock('@/lib/monitoring/metrics'); +jest.mock('@/lib/middleware/logger'); +jest.mock('bcryptjs'); + +describe('Auth API Routes', () => { + let mockLogin: jest.Mock; + let mockLogout: jest.Mock; + let mockGetCurrentUser: jest.Mock; + let mockCollectResponseTime: jest.Mock; + let mockTrackRequest: jest.Mock; + let mockTrackAuthAttempt: jest.Mock; + let mockExtractRequestMetadata: jest.Mock; + let mockLogRequest: jest.Mock; + let mockLogAuth: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockLogin = jest.fn().mockResolvedValue(undefined); + mockLogout = jest.fn().mockResolvedValue(undefined); + mockGetCurrentUser = jest.fn().mockResolvedValue(null); + mockCollectResponseTime = jest.fn().mockReturnValue(jest.fn()); + mockTrackRequest = jest.fn(); + mockTrackAuthAttempt = jest.fn(); + mockExtractRequestMetadata = jest.fn().mockReturnValue({ + method: 'POST', + path: '/api/v1/auth/login', + ip: '127.0.0.1', + userAgent: 'test-agent', + }); + mockLogRequest = jest.fn(); + mockLogAuth = jest.fn(); + + (authSession.login as jest.Mock) = mockLogin; + (authSession.logout as jest.Mock) = mockLogout; + (authSession.getCurrentUser as jest.Mock) = mockGetCurrentUser; + (metrics.collectResponseTime as jest.Mock) = mockCollectResponseTime; + (metrics.trackRequest as jest.Mock) = mockTrackRequest; + (metrics.trackAuthAttempt as jest.Mock) = mockTrackAuthAttempt; + (logger.extractRequestMetadata as jest.Mock) = mockExtractRequestMetadata; + (logger.logRequest as jest.Mock) = mockLogRequest; + (logger.logAuth as jest.Mock) = mockLogAuth; + (bcrypt.compare as jest.Mock) = jest.fn().mockResolvedValue(true); + }); + + describe('/api/v1/auth/login', () => { + it('should login successfully with valid credentials', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'admin@example.com', + password: 'password123', + }), + }); + + const response = await loginPOST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('Login successful'); + expect(data.data).toMatchObject({ + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + }); + expect(mockLogin).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'admin@example.com', + role: 'admin', + }) + ); + }); + + it('should validate email format', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'invalid-email', + password: 'password123', + }), + }); + + const response = await loginPOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('Invalid request'); + }); + + it('should validate password length', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'user@example.com', + password: '12345', // Too short + }), + }); + + const response = await loginPOST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('Invalid request'); + }); + + it('should reject invalid credentials', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'nonexistent@example.com', + password: 'password123', + }), + }); + + const response = await loginPOST(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.error).toBe('Invalid credentials'); + expect(data.message).toBe('Email or password is incorrect'); + expect(mockTrackAuthAttempt).toHaveBeenCalledWith('login', false); + }); + + it('should track successful auth attempts', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'admin@example.com', + password: 'password123', + }), + }); + + await loginPOST(request); + + expect(mockTrackAuthAttempt).toHaveBeenCalledWith('login', true); + expect(mockLogAuth).toHaveBeenCalledWith('login', 'admin@example.com', true); + }); + + it('should handle errors gracefully', async () => { + mockLogin.mockRejectedValue(new Error('Session creation failed')); + + const request = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'admin@example.com', + password: 'password123', + }), + }); + + const response = await loginPOST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('Login failed'); + expect(data.message).toBe('Session creation failed'); + }); + }); + + describe('/api/v1/auth/logout', () => { + it('should logout successfully', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/auth/logout', { + method: 'POST', + }); + + const response = await logoutPOST(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.message).toBe('Logout successful'); + expect(mockLogout).toHaveBeenCalled(); + }); + + it('should track logout attempts', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/auth/logout', { + method: 'POST', + }); + + await logoutPOST(request); + + expect(mockTrackAuthAttempt).toHaveBeenCalledWith('logout', true); + expect(mockLogAuth).toHaveBeenCalledWith('logout', 'anonymous', true); + }); + + it('should handle logout errors', async () => { + mockLogout.mockRejectedValue(new Error('Session destruction failed')); + + const request = new NextRequest('http://localhost:3000/api/v1/auth/logout', { + method: 'POST', + }); + + const response = await logoutPOST(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('Logout failed'); + }); + }); + + describe('/api/v1/auth/me', () => { + it('should return current user when authenticated', async () => { + mockGetCurrentUser.mockResolvedValue({ + id: '1', + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + }); + + const request = new NextRequest('http://localhost:3000/api/v1/auth/me'); + + const response = await meGET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toMatchObject({ + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + }); + }); + + it('should return 401 when not authenticated', async () => { + mockGetCurrentUser.mockResolvedValue(null); + + const request = new NextRequest('http://localhost:3000/api/v1/auth/me'); + + const response = await meGET(request); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.success).toBe(false); + expect(data.error).toBe('Not authenticated'); + expect(data.message).toBe('Please login to access this resource'); + }); + + it('should handle errors gracefully', async () => { + mockGetCurrentUser.mockRejectedValue(new Error('Session validation failed')); + + const request = new NextRequest('http://localhost:3000/api/v1/auth/me'); + + const response = await meGET(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('Failed to get user info'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/chainById.test.ts b/__tests__/api/chainById.test.ts new file mode 100644 index 0000000..ec1a027 --- /dev/null +++ b/__tests__/api/chainById.test.ts @@ -0,0 +1,67 @@ +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/v1/chains/[chainId]/route'; + +describe('/api/v1/chains/[chainId]', () => { + describe('GET', () => { + it('should return a specific chain by ID', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub'); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data).toMatchObject({ + id: 'cosmos-hub', + name: 'Cosmos Hub', + network: 'cosmoshub-4', + description: expect.any(String), + logoUrl: expect.any(String), + }); + }); + + it('should return 404 for non-existent chain', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/non-existent'); + const params = Promise.resolve({ chainId: 'non-existent' }); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.success).toBe(false); + expect(data.error).toBe('Chain not found'); + expect(data.message).toContain('non-existent'); + }); + + it('should handle different valid chain IDs', async () => { + const chainIds = ['cosmos-hub', 'osmosis', 'juno']; + + for (const chainId of chainIds) { + const request = new NextRequest(`http://localhost:3000/api/v1/chains/${chainId}`); + const params = Promise.resolve({ chainId }); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.id).toBe(chainId); + } + }); + + it('should handle errors gracefully', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub'); + // Simulate an error by passing a rejected promise + const params = Promise.reject(new Error('Database connection failed')); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('Failed to fetch chain'); + expect(data.message).toBe('Database connection failed'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/chains.test.ts b/__tests__/api/chains.test.ts new file mode 100644 index 0000000..70b8ea5 --- /dev/null +++ b/__tests__/api/chains.test.ts @@ -0,0 +1,94 @@ +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/v1/chains/route'; +import * as metrics from '@/lib/monitoring/metrics'; +import * as logger from '@/lib/middleware/logger'; + +// Mock the monitoring and logging modules +jest.mock('@/lib/monitoring/metrics'); +jest.mock('@/lib/middleware/logger'); + +describe('/api/v1/chains', () => { + let mockCollectResponseTime: jest.Mock; + let mockTrackRequest: jest.Mock; + let mockExtractRequestMetadata: jest.Mock; + let mockLogRequest: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockCollectResponseTime = jest.fn().mockReturnValue(jest.fn()); + mockTrackRequest = jest.fn(); + mockExtractRequestMetadata = jest.fn().mockReturnValue({ + method: 'GET', + path: '/api/v1/chains', + ip: '127.0.0.1', + userAgent: 'test-agent', + }); + mockLogRequest = jest.fn(); + + (metrics.collectResponseTime as jest.Mock) = mockCollectResponseTime; + (metrics.trackRequest as jest.Mock) = mockTrackRequest; + (logger.extractRequestMetadata as jest.Mock) = mockExtractRequestMetadata; + (logger.logRequest as jest.Mock) = mockLogRequest; + }); + + describe('GET', () => { + it('should return a list of chains successfully', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains'); + + const response = await GET(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(Array.isArray(data.data)).toBe(true); + expect(data.data.length).toBeGreaterThan(0); + + // Verify the structure of chain objects + const firstChain = data.data[0]; + expect(firstChain).toHaveProperty('id'); + expect(firstChain).toHaveProperty('name'); + expect(firstChain).toHaveProperty('network'); + expect(firstChain).toHaveProperty('description'); + expect(firstChain).toHaveProperty('logoUrl'); + }); + + it('should call monitoring metrics', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains'); + + await GET(request); + + expect(mockCollectResponseTime).toHaveBeenCalledWith('GET', '/api/v1/chains'); + expect(mockTrackRequest).toHaveBeenCalledWith('GET', '/api/v1/chains', 200); + }); + + it('should log the request', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains'); + + await GET(request); + + expect(mockExtractRequestMetadata).toHaveBeenCalledWith(request); + expect(mockLogRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/api/v1/chains', + responseStatus: 200, + responseTime: expect.any(Number), + }) + ); + }); + + it('should return known chain IDs', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains'); + + const response = await GET(request); + const data = await response.json(); + + const chainIds = data.data.map((chain: any) => chain.id); + expect(chainIds).toContain('cosmos-hub'); + expect(chainIds).toContain('osmosis'); + expect(chainIds).toContain('juno'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/download.test.ts b/__tests__/api/download.test.ts new file mode 100644 index 0000000..1d22d01 --- /dev/null +++ b/__tests__/api/download.test.ts @@ -0,0 +1,237 @@ +import { NextRequest } from 'next/server'; +import { POST } from '@/app/api/v1/chains/[chainId]/download/route'; +import * as minioClient from '@/lib/minio/client'; +import * as metrics from '@/lib/monitoring/metrics'; +import * as logger from '@/lib/middleware/logger'; +import * as bandwidthManager from '@/lib/bandwidth/manager'; +import { getIronSession } from 'iron-session'; + +// Mock dependencies +jest.mock('@/lib/minio/client'); +jest.mock('@/lib/monitoring/metrics'); +jest.mock('@/lib/middleware/logger'); +jest.mock('@/lib/bandwidth/manager'); +jest.mock('iron-session'); +jest.mock('next/headers', () => ({ + cookies: jest.fn().mockResolvedValue({ + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }), +})); + +describe('/api/v1/chains/[chainId]/download', () => { + let mockGetPresignedUrl: jest.Mock; + let mockCollectResponseTime: jest.Mock; + let mockTrackRequest: jest.Mock; + let mockTrackDownload: jest.Mock; + let mockExtractRequestMetadata: jest.Mock; + let mockLogRequest: jest.Mock; + let mockLogDownload: jest.Mock; + let mockBandwidthManager: any; + let mockGetIronSession: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockGetPresignedUrl = jest.fn().mockResolvedValue('https://minio.example.com/download-url'); + mockCollectResponseTime = jest.fn().mockReturnValue(jest.fn()); + mockTrackRequest = jest.fn(); + mockTrackDownload = jest.fn(); + mockExtractRequestMetadata = jest.fn().mockReturnValue({ + method: 'POST', + path: '/api/v1/chains/cosmos-hub/download', + ip: '127.0.0.1', + userAgent: 'test-agent', + }); + mockLogRequest = jest.fn(); + mockLogDownload = jest.fn(); + + mockBandwidthManager = { + hasExceededLimit: jest.fn().mockReturnValue(false), + startConnection: jest.fn(), + }; + + mockGetIronSession = jest.fn().mockResolvedValue({ + username: 'testuser', + tier: 'free', + }); + + (minioClient.getPresignedUrl as jest.Mock) = mockGetPresignedUrl; + (metrics.collectResponseTime as jest.Mock) = mockCollectResponseTime; + (metrics.trackRequest as jest.Mock) = mockTrackRequest; + (metrics.trackDownload as jest.Mock) = mockTrackDownload; + (logger.extractRequestMetadata as jest.Mock) = mockExtractRequestMetadata; + (logger.logRequest as jest.Mock) = mockLogRequest; + (logger.logDownload as jest.Mock) = mockLogDownload; + (bandwidthManager.bandwidthManager as any) = mockBandwidthManager; + (getIronSession as jest.Mock) = mockGetIronSession; + }); + + describe('POST', () => { + it('should generate download URL successfully', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + email: 'user@example.com', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await POST(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.downloadUrl).toBe('https://minio.example.com/download-url'); + expect(data.message).toBe('Download URL generated successfully'); + }); + + it('should validate request body', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + // Missing snapshotId + email: 'user@example.com', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await POST(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('Invalid request'); + }); + + it('should validate email format when provided', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + email: 'invalid-email', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await POST(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe('Invalid request'); + }); + + it('should handle bandwidth limit exceeded', async () => { + mockBandwidthManager.hasExceededLimit.mockReturnValue(true); + + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await POST(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data.success).toBe(false); + expect(data.error).toBe('Bandwidth limit exceeded'); + expect(data.message).toBe('You have exceeded your monthly bandwidth limit'); + }); + + it('should work without email', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await POST(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.downloadUrl).toBe('https://minio.example.com/download-url'); + }); + + it('should track download metrics', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + await POST(request, { params }); + + expect(mockTrackDownload).toHaveBeenCalledWith('free', 'snapshot-123'); + expect(mockLogDownload).toHaveBeenCalledWith('testuser', 'snapshot-123', 'free', true); + }); + + it('should start bandwidth connection tracking', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + await POST(request, { params }); + + expect(mockBandwidthManager.startConnection).toHaveBeenCalledWith( + expect.stringContaining('testuser-snapshot-123-'), + 'testuser', + 'free' + ); + }); + + it('should handle anonymous users', async () => { + mockGetIronSession.mockResolvedValue(null); + + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await POST(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockBandwidthManager.hasExceededLimit).toHaveBeenCalledWith('anonymous', 'free'); + }); + + it('should handle errors gracefully', async () => { + mockGetPresignedUrl.mockRejectedValue(new Error('MinIO connection failed')); + + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await POST(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('Failed to generate download URL'); + expect(data.message).toBe('MinIO connection failed'); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/health.test.ts b/__tests__/api/health.test.ts new file mode 100644 index 0000000..141d2a4 --- /dev/null +++ b/__tests__/api/health.test.ts @@ -0,0 +1,100 @@ +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/health/route'; +import * as minioClient from '@/lib/minio/client'; + +// Mock MinIO client +jest.mock('@/lib/minio/client'); + +describe('/api/health', () => { + let mockGetMinioClient: jest.Mock; + let mockListBuckets: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockListBuckets = jest.fn().mockResolvedValue([]); + mockGetMinioClient = jest.fn().mockReturnValue({ + listBuckets: mockListBuckets, + }); + + (minioClient.getMinioClient as jest.Mock) = mockGetMinioClient; + }); + + describe('GET', () => { + it('should return healthy status when all services are working', async () => { + const request = new NextRequest('http://localhost:3000/api/health'); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.status).toBe('healthy'); + expect(data.data.services).toEqual({ + database: true, + minio: true, + }); + expect(data.data.timestamp).toBeDefined(); + }); + + it('should return unhealthy status when MinIO is down', async () => { + mockListBuckets.mockRejectedValue(new Error('Connection refused')); + + const request = new NextRequest('http://localhost:3000/api/health'); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(data.data.status).toBe('unhealthy'); + expect(data.data.services).toEqual({ + database: true, + minio: false, + }); + }); + + it('should check MinIO connection', async () => { + const request = new NextRequest('http://localhost:3000/api/health'); + + await GET(); + + expect(mockGetMinioClient).toHaveBeenCalled(); + expect(mockListBuckets).toHaveBeenCalled(); + }); + + it('should include timestamp in ISO format', async () => { + const request = new NextRequest('http://localhost:3000/api/health'); + + const response = await GET(); + const data = await response.json(); + + const timestamp = new Date(data.data.timestamp); + expect(timestamp.toISOString()).toBe(data.data.timestamp); + expect(timestamp.getTime()).toBeLessThanOrEqual(Date.now()); + }); + + it('should handle unexpected errors', async () => { + // Mock console.error to suppress error output in tests + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + + // Force an unexpected error by mocking getMinioClient to throw + mockGetMinioClient.mockImplementation(() => { + throw new Error('Unexpected error'); + }); + + const request = new NextRequest('http://localhost:3000/api/health'); + + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('Health check failed'); + expect(data.message).toBe('Unexpected error'); + + consoleError.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/api/snapshots.test.ts b/__tests__/api/snapshots.test.ts new file mode 100644 index 0000000..dc2c344 --- /dev/null +++ b/__tests__/api/snapshots.test.ts @@ -0,0 +1,104 @@ +import { NextRequest } from 'next/server'; +import { GET } from '@/app/api/v1/chains/[chainId]/snapshots/route'; + +describe('/api/v1/chains/[chainId]/snapshots', () => { + describe('GET', () => { + it('should return snapshots for a valid chain', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/snapshots'); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(Array.isArray(data.data)).toBe(true); + expect(data.data.length).toBeGreaterThan(0); + + // Verify snapshot structure + const firstSnapshot = data.data[0]; + expect(firstSnapshot).toHaveProperty('id'); + expect(firstSnapshot).toHaveProperty('chainId', 'cosmos-hub'); + expect(firstSnapshot).toHaveProperty('height'); + expect(firstSnapshot).toHaveProperty('size'); + expect(firstSnapshot).toHaveProperty('fileName'); + expect(firstSnapshot).toHaveProperty('createdAt'); + expect(firstSnapshot).toHaveProperty('updatedAt'); + expect(firstSnapshot).toHaveProperty('type'); + expect(firstSnapshot).toHaveProperty('compressionType'); + }); + + it('should return empty array for chain with no snapshots', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/unknown-chain/snapshots'); + const params = Promise.resolve({ chainId: 'unknown-chain' }); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(Array.isArray(data.data)).toBe(true); + expect(data.data.length).toBe(0); + }); + + it('should return snapshots for different chains', async () => { + const chains = ['cosmos-hub', 'osmosis', 'juno']; + + for (const chainId of chains) { + const request = new NextRequest(`http://localhost:3000/api/v1/chains/${chainId}/snapshots`); + const params = Promise.resolve({ chainId }); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(Array.isArray(data.data)).toBe(true); + + // All snapshots should belong to the requested chain + data.data.forEach((snapshot: any) => { + expect(snapshot.chainId).toBe(chainId); + }); + } + }); + + it('should handle errors gracefully', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/snapshots'); + const params = Promise.reject(new Error('Database connection failed')); + + const response = await GET(request, { params }); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data.success).toBe(false); + expect(data.error).toBe('Failed to fetch snapshots'); + expect(data.message).toBe('Database connection failed'); + }); + + it('should return snapshots with valid types', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/snapshots'); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await GET(request, { params }); + const data = await response.json(); + + const validTypes = ['pruned', 'archive']; + data.data.forEach((snapshot: any) => { + expect(validTypes).toContain(snapshot.type); + }); + }); + + it('should return snapshots with valid compression types', async () => { + const request = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/snapshots'); + const params = Promise.resolve({ chainId: 'cosmos-hub' }); + + const response = await GET(request, { params }); + const data = await response.json(); + + const validCompressionTypes = ['lz4', 'zst', 'gz']; + data.data.forEach((snapshot: any) => { + expect(validCompressionTypes).toContain(snapshot.compressionType); + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/components/ChainList.test.tsx b/__tests__/components/ChainList.test.tsx new file mode 100644 index 0000000..2c0874c --- /dev/null +++ b/__tests__/components/ChainList.test.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ChainList } from '@/components/chains/ChainList'; +import { useChains } from '@/hooks/useChains'; +import { Chain } from '@/lib/types'; + +// Mock dependencies +jest.mock('@/hooks/useChains'); +jest.mock('@/components/chains/ChainCard', () => ({ + ChainCard: ({ chain }: { chain: Chain }) => ( +
{chain.name}
+ ), +})); + +describe('ChainList', () => { + let mockUseChains: jest.Mock; + let mockChains: Chain[]; + let mockRefetch: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseChains = useChains as jest.Mock; + mockRefetch = jest.fn(); + + mockChains = [ + { + id: 'cosmos-hub', + name: 'Cosmos Hub', + network: 'cosmoshub-4', + description: 'The Cosmos Hub', + logoUrl: '/cosmos.png', + }, + { + id: 'osmosis', + name: 'Osmosis', + network: 'osmosis-1', + description: 'Osmosis DEX', + logoUrl: '/osmosis.png', + }, + { + id: 'juno', + name: 'Juno', + network: 'juno-1', + description: 'Juno Network', + logoUrl: '/juno.png', + }, + ]; + + mockUseChains.mockReturnValue({ + chains: mockChains, + loading: false, + error: null, + refetch: mockRefetch, + }); + }); + + it('should render chain list with all chains', () => { + render(); + + expect(screen.getByTestId('chain-card-cosmos-hub')).toBeInTheDocument(); + expect(screen.getByTestId('chain-card-osmosis')).toBeInTheDocument(); + expect(screen.getByTestId('chain-card-juno')).toBeInTheDocument(); + expect(screen.getByText('Showing 3 of 3 chains')).toBeInTheDocument(); + }); + + it('should show loading state', () => { + mockUseChains.mockReturnValue({ + chains: null, + loading: true, + error: null, + refetch: mockRefetch, + }); + + render(); + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('should show error state', () => { + mockUseChains.mockReturnValue({ + chains: null, + loading: false, + error: 'Failed to fetch chains', + refetch: mockRefetch, + }); + + render(); + + expect(screen.getByText('Failed to load chains')).toBeInTheDocument(); + expect(screen.getByText('Failed to fetch chains')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + + it('should handle retry on error', () => { + mockUseChains.mockReturnValue({ + chains: null, + loading: false, + error: 'Failed to fetch chains', + refetch: mockRefetch, + }); + + render(); + + const retryButton = screen.getByRole('button', { name: /retry/i }); + fireEvent.click(retryButton); + + expect(mockRefetch).toHaveBeenCalled(); + }); + + it('should filter chains by search term', async () => { + const user = userEvent.setup(); + + render(); + + const searchInput = screen.getByPlaceholderText('Search chains...'); + await user.type(searchInput, 'cosmos'); + + expect(screen.getByTestId('chain-card-cosmos-hub')).toBeInTheDocument(); + expect(screen.queryByTestId('chain-card-osmosis')).not.toBeInTheDocument(); + expect(screen.queryByTestId('chain-card-juno')).not.toBeInTheDocument(); + expect(screen.getByText('Showing 1 of 3 chains')).toBeInTheDocument(); + }); + + it('should filter chains by network', () => { + render(); + + const networkSelect = screen.getByRole('combobox'); + fireEvent.change(networkSelect, { target: { value: 'osmosis-1' } }); + + expect(screen.queryByTestId('chain-card-cosmos-hub')).not.toBeInTheDocument(); + expect(screen.getByTestId('chain-card-osmosis')).toBeInTheDocument(); + expect(screen.queryByTestId('chain-card-juno')).not.toBeInTheDocument(); + expect(screen.getByText('Showing 1 of 3 chains')).toBeInTheDocument(); + }); + + it('should combine search and network filters', async () => { + const user = userEvent.setup(); + + render(); + + const searchInput = screen.getByPlaceholderText('Search chains...'); + const networkSelect = screen.getByRole('combobox'); + + await user.type(searchInput, 'os'); + fireEvent.change(networkSelect, { target: { value: 'osmosis-1' } }); + + expect(screen.queryByTestId('chain-card-cosmos-hub')).not.toBeInTheDocument(); + expect(screen.getByTestId('chain-card-osmosis')).toBeInTheDocument(); + expect(screen.queryByTestId('chain-card-juno')).not.toBeInTheDocument(); + }); + + it('should show no results message', async () => { + const user = userEvent.setup(); + + render(); + + const searchInput = screen.getByPlaceholderText('Search chains...'); + await user.type(searchInput, 'nonexistent'); + + expect(screen.getByText('No chains found matching your criteria')).toBeInTheDocument(); + expect(screen.getByText('Showing 0 of 3 chains')).toBeInTheDocument(); + }); + + it('should populate network dropdown with unique networks', () => { + render(); + + const networkSelect = screen.getByRole('combobox'); + const options = networkSelect.querySelectorAll('option'); + + expect(options).toHaveLength(4); // All Networks + 3 unique networks + expect(options[0]).toHaveTextContent('All Networks'); + expect(options[1]).toHaveTextContent('cosmoshub-4'); + expect(options[2]).toHaveTextContent('juno-1'); + expect(options[3]).toHaveTextContent('osmosis-1'); + }); + + it('should be case-insensitive in search', async () => { + const user = userEvent.setup(); + + render(); + + const searchInput = screen.getByPlaceholderText('Search chains...'); + await user.type(searchInput, 'COSMOS'); + + expect(screen.getByTestId('chain-card-cosmos-hub')).toBeInTheDocument(); + }); + + it('should search by chain ID as well as name', async () => { + const user = userEvent.setup(); + + render(); + + const searchInput = screen.getByPlaceholderText('Search chains...'); + await user.type(searchInput, 'cosmos-hub'); + + expect(screen.getByTestId('chain-card-cosmos-hub')).toBeInTheDocument(); + expect(screen.queryByTestId('chain-card-osmosis')).not.toBeInTheDocument(); + }); + + it('should handle empty chains array', () => { + mockUseChains.mockReturnValue({ + chains: [], + loading: false, + error: null, + refetch: mockRefetch, + }); + + render(); + + expect(screen.getByText('Showing 0 of 0 chains')).toBeInTheDocument(); + expect(screen.getByText('No chains found matching your criteria')).toBeInTheDocument(); + }); + + it('should reset to all networks when selecting "All Networks"', () => { + render(); + + const networkSelect = screen.getByRole('combobox'); + + // First filter by a specific network + fireEvent.change(networkSelect, { target: { value: 'osmosis-1' } }); + expect(screen.getByText('Showing 1 of 3 chains')).toBeInTheDocument(); + + // Then reset to all networks + fireEvent.change(networkSelect, { target: { value: 'all' } }); + expect(screen.getByText('Showing 3 of 3 chains')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/__tests__/components/DownloadButton.test.tsx b/__tests__/components/DownloadButton.test.tsx new file mode 100644 index 0000000..9f58942 --- /dev/null +++ b/__tests__/components/DownloadButton.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { DownloadButton } from '@/components/snapshots/DownloadButton'; +import { useAuth } from '@/components/providers/AuthProvider'; +import { Snapshot } from '@/lib/types'; + +// Mock dependencies +jest.mock('@/components/providers/AuthProvider'); + +// Mock fetch +global.fetch = jest.fn(); + +describe('DownloadButton', () => { + let mockUseAuth: jest.Mock; + let mockFetch: jest.Mock; + let mockSnapshot: Snapshot; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockUseAuth = useAuth as jest.Mock; + mockFetch = global.fetch as jest.Mock; + + mockUseAuth.mockReturnValue({ + user: { email: 'user@example.com' }, + }); + + mockSnapshot = { + id: 'snapshot-123', + chainId: 'cosmos-hub', + height: 19234567, + size: 450 * 1024 * 1024 * 1024, + fileName: 'cosmoshub-4-19234567.tar.lz4', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + type: 'pruned', + compressionType: 'lz4', + }; + + // Mock successful download response + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ + success: true, + data: { + downloadUrl: 'https://example.com/download/test-file', + }, + }), + }); + + // Mock createElement and appendChild + const mockLink = { + href: '', + download: '', + click: jest.fn(), + remove: jest.fn(), + }; + + jest.spyOn(document, 'createElement').mockReturnValue(mockLink as any); + jest.spyOn(document.body, 'appendChild').mockImplementation(); + jest.spyOn(document.body, 'removeChild').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should render download button', () => { + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + it('should handle download click', async () => { + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/v1/chains/cosmos-hub/download', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + snapshotId: 'snapshot-123', + email: 'user@example.com', + }), + } + ); + }); + }); + + it('should show loading state during download', async () => { + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText('Downloading...')).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + }); + + it('should create and click download link', async () => { + const mockLink = { + href: '', + download: '', + click: jest.fn(), + }; + + const createElementSpy = jest.spyOn(document, 'createElement') + .mockReturnValue(mockLink as any); + + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(mockLink.href).toBe('https://example.com/download/test-file'); + expect(mockLink.download).toBe('cosmoshub-4-19234567.tar.lz4'); + expect(mockLink.click).toHaveBeenCalled(); + }); + }); + + it('should show progress bar during download', async () => { + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + const progressBar = screen.getByRole('progressbar', { hidden: true }); + expect(progressBar).toBeInTheDocument(); + }); + }); + + it('should handle download without user email', async () => { + mockUseAuth.mockReturnValue({ + user: null, + }); + + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/v1/chains/cosmos-hub/download', + expect.objectContaining({ + body: JSON.stringify({ + snapshotId: 'snapshot-123', + email: undefined, + }), + }) + ); + }); + }); + + it('should handle download errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Download failed' }), + }); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(consoleError).toHaveBeenCalledWith( + 'Download failed:', + expect.any(Error) + ); + expect(button).not.toBeDisabled(); + expect(screen.queryByText('Downloading...')).not.toBeInTheDocument(); + }); + + consoleError.mockRestore(); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(consoleError).toHaveBeenCalledWith( + 'Download failed:', + expect.any(Error) + ); + expect(button).not.toBeDisabled(); + }); + + consoleError.mockRestore(); + }); + + it('should reset state after download completes', async () => { + jest.useFakeTimers(); + + render( + + ); + + const button = screen.getByRole('button', { name: /download/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(screen.getByText('Downloading...')).toBeInTheDocument(); + }); + + // Fast-forward through the simulated download + jest.advanceTimersByTime(10000); + + await waitFor(() => { + expect(screen.queryByText('Downloading...')).not.toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + jest.useRealTimers(); + }); +}); \ No newline at end of file diff --git a/__tests__/components/LoginForm.test.tsx b/__tests__/components/LoginForm.test.tsx new file mode 100644 index 0000000..009a3bb --- /dev/null +++ b/__tests__/components/LoginForm.test.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LoginForm } from '@/components/auth/LoginForm'; +import { useAuth } from '@/components/providers/AuthProvider'; +import { useRouter } from 'next/navigation'; + +// Mock dependencies +jest.mock('@/components/providers/AuthProvider'); +jest.mock('next/navigation'); + +describe('LoginForm', () => { + let mockLogin: jest.Mock; + let mockPush: jest.Mock; + let mockUseAuth: jest.Mock; + let mockUseRouter: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockLogin = jest.fn(); + mockPush = jest.fn(); + + mockUseAuth = useAuth as jest.Mock; + mockUseRouter = useRouter as jest.Mock; + + mockUseAuth.mockReturnValue({ + login: mockLogin, + error: null, + }); + + mockUseRouter.mockReturnValue({ + push: mockPush, + }); + }); + + it('should render login form', () => { + render(); + + expect(screen.getByText('Login to BryanLabs Snapshots')).toBeInTheDocument(); + expect(screen.getByLabelText('Email Address')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument(); + }); + + it('should handle form submission with valid credentials', async () => { + mockLogin.mockResolvedValue(true); + const user = userEvent.setup(); + + render(); + + const emailInput = screen.getByLabelText('Email Address'); + const passwordInput = screen.getByLabelText('Password'); + const submitButton = screen.getByRole('button', { name: 'Sign In' }); + + await user.type(emailInput, 'user@example.com'); + await user.type(passwordInput, 'password123'); + await user.click(submitButton); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith({ + email: 'user@example.com', + password: 'password123', + }); + expect(mockPush).toHaveBeenCalledWith('/'); + }); + }); + + it('should show loading state during submission', async () => { + mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(true), 100))); + const user = userEvent.setup(); + + render(); + + const emailInput = screen.getByLabelText('Email Address'); + const passwordInput = screen.getByLabelText('Password'); + const submitButton = screen.getByRole('button', { name: 'Sign In' }); + + await user.type(emailInput, 'user@example.com'); + await user.type(passwordInput, 'password123'); + await user.click(submitButton); + + expect(submitButton).toBeDisabled(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(submitButton).not.toBeDisabled(); + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + }); + }); + + it('should display error message when login fails', async () => { + mockUseAuth.mockReturnValue({ + login: mockLogin, + error: 'Invalid email or password', + }); + + render(); + + expect(screen.getByText('Invalid email or password')).toBeInTheDocument(); + }); + + it('should not redirect when login fails', async () => { + mockLogin.mockResolvedValue(false); + const user = userEvent.setup(); + + render(); + + const emailInput = screen.getByLabelText('Email Address'); + const passwordInput = screen.getByLabelText('Password'); + const submitButton = screen.getByRole('button', { name: 'Sign In' }); + + await user.type(emailInput, 'user@example.com'); + await user.type(passwordInput, 'wrongpassword'); + await user.click(submitButton); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalled(); + expect(mockPush).not.toHaveBeenCalled(); + }); + }); + + it('should validate email format', async () => { + render(); + + const emailInput = screen.getByLabelText('Email Address') as HTMLInputElement; + + expect(emailInput).toHaveAttribute('type', 'email'); + expect(emailInput).toHaveAttribute('required'); + }); + + it('should validate password is required', async () => { + render(); + + const passwordInput = screen.getByLabelText('Password') as HTMLInputElement; + + expect(passwordInput).toHaveAttribute('type', 'password'); + expect(passwordInput).toHaveAttribute('required'); + }); + + it('should update input values on change', async () => { + const user = userEvent.setup(); + + render(); + + const emailInput = screen.getByLabelText('Email Address') as HTMLInputElement; + const passwordInput = screen.getByLabelText('Password') as HTMLInputElement; + + await user.type(emailInput, 'test@example.com'); + await user.type(passwordInput, 'mypassword'); + + expect(emailInput.value).toBe('test@example.com'); + expect(passwordInput.value).toBe('mypassword'); + }); + + it('should prevent form submission when loading', async () => { + mockLogin.mockImplementation(() => new Promise(() => {})); // Never resolves + const user = userEvent.setup(); + + render(); + + const emailInput = screen.getByLabelText('Email Address'); + const passwordInput = screen.getByLabelText('Password'); + const submitButton = screen.getByRole('button', { name: 'Sign In' }); + + await user.type(emailInput, 'user@example.com'); + await user.type(passwordInput, 'password123'); + await user.click(submitButton); + + // Try to click again while loading + await user.click(submitButton); + + // Login should only be called once + expect(mockLogin).toHaveBeenCalledTimes(1); + }); + + it('should handle form submission with enter key', async () => { + mockLogin.mockResolvedValue(true); + const user = userEvent.setup(); + + render(); + + const emailInput = screen.getByLabelText('Email Address'); + const passwordInput = screen.getByLabelText('Password'); + + await user.type(emailInput, 'user@example.com'); + await user.type(passwordInput, 'password123'); + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith({ + email: 'user@example.com', + password: 'password123', + }); + }); + }); + + it('should have proper accessibility attributes', () => { + render(); + + const form = screen.getByRole('form', { hidden: true }); + const emailInput = screen.getByLabelText('Email Address'); + const passwordInput = screen.getByLabelText('Password'); + + expect(emailInput).toHaveAttribute('id', 'email'); + expect(passwordInput).toHaveAttribute('id', 'password'); + expect(emailInput).toHaveAttribute('placeholder', 'you@example.com'); + expect(passwordInput).toHaveAttribute('placeholder', '••••••••'); + }); +}); \ No newline at end of file diff --git a/__tests__/integration/auth-flow.test.ts b/__tests__/integration/auth-flow.test.ts new file mode 100644 index 0000000..e4697f7 --- /dev/null +++ b/__tests__/integration/auth-flow.test.ts @@ -0,0 +1,255 @@ +import { NextRequest } from 'next/server'; +import { POST as loginPOST } from '@/app/api/v1/auth/login/route'; +import { POST as logoutPOST } from '@/app/api/v1/auth/logout/route'; +import { GET as meGET } from '@/app/api/v1/auth/me/route'; +import { getIronSession } from 'iron-session'; + +// Mock iron-session +jest.mock('iron-session'); +jest.mock('next/headers', () => ({ + cookies: jest.fn().mockResolvedValue({ + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }), +})); + +describe('Authentication Flow Integration', () => { + let mockGetIronSession: jest.Mock; + let mockSession: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup session mock + mockSession = { + user: undefined, + isLoggedIn: false, + save: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + }; + + mockGetIronSession = getIronSession as jest.Mock; + mockGetIronSession.mockResolvedValue(mockSession); + }); + + describe('Complete login flow', () => { + it('should handle login -> verify user -> logout flow', async () => { + // Step 1: Login + const loginRequest = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'admin@example.com', + password: 'password123', + }), + }); + + const loginResponse = await loginPOST(loginRequest); + const loginData = await loginResponse.json(); + + expect(loginResponse.status).toBe(200); + expect(loginData.success).toBe(true); + expect(loginData.data.email).toBe('admin@example.com'); + + // Simulate session being set + mockSession.user = loginData.data; + mockSession.isLoggedIn = true; + + // Step 2: Verify user is logged in + const meRequest = new NextRequest('http://localhost:3000/api/v1/auth/me'); + + const meResponse = await meGET(meRequest); + const meData = await meResponse.json(); + + expect(meResponse.status).toBe(200); + expect(meData.success).toBe(true); + expect(meData.data.email).toBe('admin@example.com'); + + // Step 3: Logout + const logoutRequest = new NextRequest('http://localhost:3000/api/v1/auth/logout', { + method: 'POST', + }); + + const logoutResponse = await logoutPOST(logoutRequest); + const logoutData = await logoutResponse.json(); + + expect(logoutResponse.status).toBe(200); + expect(logoutData.success).toBe(true); + expect(mockSession.destroy).toHaveBeenCalled(); + + // Step 4: Verify user is logged out + mockSession.user = undefined; + mockSession.isLoggedIn = false; + + const meAfterLogoutResponse = await meGET(meRequest); + const meAfterLogoutData = await meAfterLogoutResponse.json(); + + expect(meAfterLogoutResponse.status).toBe(401); + expect(meAfterLogoutData.success).toBe(false); + expect(meAfterLogoutData.error).toBe('Not authenticated'); + }); + + it('should handle failed login attempts', async () => { + // Attempt 1: Invalid email format + const invalidEmailRequest = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'not-an-email', + password: 'password123', + }), + }); + + const invalidEmailResponse = await loginPOST(invalidEmailRequest); + const invalidEmailData = await invalidEmailResponse.json(); + + expect(invalidEmailResponse.status).toBe(400); + expect(invalidEmailData.success).toBe(false); + expect(invalidEmailData.error).toBe('Invalid request'); + + // Attempt 2: Short password + const shortPasswordRequest = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'user@example.com', + password: '12345', + }), + }); + + const shortPasswordResponse = await loginPOST(shortPasswordRequest); + const shortPasswordData = await shortPasswordResponse.json(); + + expect(shortPasswordResponse.status).toBe(400); + expect(shortPasswordData.success).toBe(false); + + // Attempt 3: Non-existent user + const nonExistentUserRequest = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'nonexistent@example.com', + password: 'password123', + }), + }); + + const nonExistentUserResponse = await loginPOST(nonExistentUserRequest); + const nonExistentUserData = await nonExistentUserResponse.json(); + + expect(nonExistentUserResponse.status).toBe(401); + expect(nonExistentUserData.success).toBe(false); + expect(nonExistentUserData.error).toBe('Invalid credentials'); + }); + + it('should handle multiple login sessions', async () => { + // Login as regular user + const userLoginRequest = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'user@example.com', + password: 'password123', + }), + }); + + const userLoginResponse = await loginPOST(userLoginRequest); + const userLoginData = await userLoginResponse.json(); + + expect(userLoginResponse.status).toBe(200); + expect(userLoginData.data.role).toBe('user'); + + // Simulate session update + mockSession.user = userLoginData.data; + mockSession.isLoggedIn = true; + + // Logout + const logoutRequest = new NextRequest('http://localhost:3000/api/v1/auth/logout', { + method: 'POST', + }); + + await logoutPOST(logoutRequest); + + // Login as admin + mockSession.user = undefined; + mockSession.isLoggedIn = false; + + const adminLoginRequest = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'admin@example.com', + password: 'password123', + }), + }); + + const adminLoginResponse = await loginPOST(adminLoginRequest); + const adminLoginData = await adminLoginResponse.json(); + + expect(adminLoginResponse.status).toBe(200); + expect(adminLoginData.data.role).toBe('admin'); + }); + + it('should persist session across requests', async () => { + // Login + const loginRequest = new NextRequest('http://localhost:3000/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ + email: 'admin@example.com', + password: 'password123', + }), + }); + + await loginPOST(loginRequest); + + // Simulate session persistence + mockSession.user = { + id: '1', + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + }; + mockSession.isLoggedIn = true; + + // Make multiple authenticated requests + const meRequest = new NextRequest('http://localhost:3000/api/v1/auth/me'); + + for (let i = 0; i < 3; i++) { + const response = await meGET(meRequest); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.data.email).toBe('admin@example.com'); + } + + // Session should still be valid + expect(mockSession.isLoggedIn).toBe(true); + }); + }); + + describe('Session management edge cases', () => { + it('should handle corrupted session data', async () => { + // Set corrupted session data + mockSession.user = { invalidData: true }; // Missing required fields + mockSession.isLoggedIn = true; + + const request = new NextRequest('http://localhost:3000/api/v1/auth/me'); + const response = await meGET(request); + const data = await response.json(); + + // Should handle gracefully + expect(response.status).toBe(200); + expect(data.data).toHaveProperty('invalidData', true); + }); + + it('should handle session without login flag', async () => { + // Set user data but not logged in flag + mockSession.user = { + id: '1', + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + }; + mockSession.isLoggedIn = false; + + const request = new NextRequest('http://localhost:3000/api/v1/auth/me'); + const response = await meGET(request); + + expect(response.status).toBe(401); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/integration/download-flow.test.ts b/__tests__/integration/download-flow.test.ts new file mode 100644 index 0000000..455fd86 --- /dev/null +++ b/__tests__/integration/download-flow.test.ts @@ -0,0 +1,306 @@ +import { NextRequest } from 'next/server'; +import { GET as getChainsGET } from '@/app/api/v1/chains/route'; +import { GET as getChainGET } from '@/app/api/v1/chains/[chainId]/route'; +import { GET as getSnapshotsGET } from '@/app/api/v1/chains/[chainId]/snapshots/route'; +import { POST as downloadPOST } from '@/app/api/v1/chains/[chainId]/download/route'; +import * as minioClient from '@/lib/minio/client'; +import * as bandwidthManager from '@/lib/bandwidth/manager'; +import { getIronSession } from 'iron-session'; + +// Mock dependencies +jest.mock('@/lib/minio/client'); +jest.mock('@/lib/bandwidth/manager'); +jest.mock('iron-session'); +jest.mock('next/headers', () => ({ + cookies: jest.fn().mockResolvedValue({ + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }), +})); + +describe('Download Flow Integration', () => { + let mockGetPresignedUrl: jest.Mock; + let mockBandwidthManager: any; + let mockGetIronSession: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockGetPresignedUrl = jest.fn().mockResolvedValue('https://minio.example.com/download-url'); + mockBandwidthManager = { + hasExceededLimit: jest.fn().mockReturnValue(false), + startConnection: jest.fn(), + getUserBandwidth: jest.fn().mockReturnValue(1024 * 1024), // 1 MB used + }; + mockGetIronSession = jest.fn().mockResolvedValue({ + username: 'testuser', + tier: 'free', + }); + + (minioClient.getPresignedUrl as jest.Mock) = mockGetPresignedUrl; + (bandwidthManager.bandwidthManager as any) = mockBandwidthManager; + (getIronSession as jest.Mock) = mockGetIronSession; + }); + + describe('Complete download flow', () => { + it('should handle chain discovery -> snapshot selection -> download flow', async () => { + // Step 1: Get list of chains + const chainsRequest = new NextRequest('http://localhost:3000/api/v1/chains'); + const chainsResponse = await getChainsGET(chainsRequest); + const chainsData = await chainsResponse.json(); + + expect(chainsResponse.status).toBe(200); + expect(chainsData.success).toBe(true); + expect(Array.isArray(chainsData.data)).toBe(true); + expect(chainsData.data.length).toBeGreaterThan(0); + + const firstChain = chainsData.data[0]; + + // Step 2: Get specific chain details + const chainRequest = new NextRequest(`http://localhost:3000/api/v1/chains/${firstChain.id}`); + const chainParams = Promise.resolve({ chainId: firstChain.id }); + const chainResponse = await getChainGET(chainRequest, { params: chainParams }); + const chainData = await chainResponse.json(); + + expect(chainResponse.status).toBe(200); + expect(chainData.success).toBe(true); + expect(chainData.data.id).toBe(firstChain.id); + + // Step 3: Get snapshots for the chain + const snapshotsRequest = new NextRequest(`http://localhost:3000/api/v1/chains/${firstChain.id}/snapshots`); + const snapshotsParams = Promise.resolve({ chainId: firstChain.id }); + const snapshotsResponse = await getSnapshotsGET(snapshotsRequest, { params: snapshotsParams }); + const snapshotsData = await snapshotsResponse.json(); + + expect(snapshotsResponse.status).toBe(200); + expect(snapshotsData.success).toBe(true); + expect(Array.isArray(snapshotsData.data)).toBe(true); + + // Skip if no snapshots available + if (snapshotsData.data.length === 0) { + return; + } + + const firstSnapshot = snapshotsData.data[0]; + + // Step 4: Request download URL + const downloadRequest = new NextRequest(`http://localhost:3000/api/v1/chains/${firstChain.id}/download`, { + method: 'POST', + body: JSON.stringify({ + snapshotId: firstSnapshot.id, + email: 'user@example.com', + }), + }); + const downloadParams = Promise.resolve({ chainId: firstChain.id }); + const downloadResponse = await downloadPOST(downloadRequest, { params: downloadParams }); + const downloadData = await downloadResponse.json(); + + expect(downloadResponse.status).toBe(200); + expect(downloadData.success).toBe(true); + expect(downloadData.data.downloadUrl).toBe('https://minio.example.com/download-url'); + + // Verify bandwidth tracking was initiated + expect(mockBandwidthManager.startConnection).toHaveBeenCalledWith( + expect.stringContaining('testuser'), + 'testuser', + 'free' + ); + }); + + it('should handle anonymous user download flow', async () => { + // Set up anonymous session + mockGetIronSession.mockResolvedValue(null); + + // Get chain and snapshot info + const chainId = 'cosmos-hub'; + const snapshotsRequest = new NextRequest(`http://localhost:3000/api/v1/chains/${chainId}/snapshots`); + const snapshotsParams = Promise.resolve({ chainId }); + const snapshotsResponse = await getSnapshotsGET(snapshotsRequest, { params: snapshotsParams }); + const snapshotsData = await snapshotsResponse.json(); + + const snapshot = snapshotsData.data[0]; + + // Request download as anonymous user + const downloadRequest = new NextRequest(`http://localhost:3000/api/v1/chains/${chainId}/download`, { + method: 'POST', + body: JSON.stringify({ + snapshotId: snapshot.id, + }), + }); + const downloadParams = Promise.resolve({ chainId }); + const downloadResponse = await downloadPOST(downloadRequest, { params: downloadParams }); + const downloadData = await downloadResponse.json(); + + expect(downloadResponse.status).toBe(200); + expect(downloadData.success).toBe(true); + + // Verify anonymous user handling + expect(mockBandwidthManager.hasExceededLimit).toHaveBeenCalledWith('anonymous', 'free'); + expect(mockBandwidthManager.startConnection).toHaveBeenCalledWith( + expect.stringContaining('anonymous'), + 'anonymous', + 'free' + ); + }); + + it('should enforce bandwidth limits', async () => { + // Set user as exceeding bandwidth limit + mockBandwidthManager.hasExceededLimit.mockReturnValue(true); + + const downloadRequest = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const downloadParams = Promise.resolve({ chainId: 'cosmos-hub' }); + const downloadResponse = await downloadPOST(downloadRequest, { params: downloadParams }); + const downloadData = await downloadResponse.json(); + + expect(downloadResponse.status).toBe(429); + expect(downloadData.success).toBe(false); + expect(downloadData.error).toBe('Bandwidth limit exceeded'); + expect(mockBandwidthManager.startConnection).not.toHaveBeenCalled(); + }); + + it('should handle premium user with higher limits', async () => { + // Set up premium user session + mockGetIronSession.mockResolvedValue({ + username: 'premiumuser', + tier: 'premium', + }); + + const downloadRequest = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + email: 'premium@example.com', + }), + }); + const downloadParams = Promise.resolve({ chainId: 'cosmos-hub' }); + const downloadResponse = await downloadPOST(downloadRequest, { params: downloadParams }); + const downloadData = await downloadResponse.json(); + + expect(downloadResponse.status).toBe(200); + expect(downloadData.success).toBe(true); + + // Verify premium tier handling + expect(mockBandwidthManager.hasExceededLimit).toHaveBeenCalledWith('premiumuser', 'premium'); + expect(mockBandwidthManager.startConnection).toHaveBeenCalledWith( + expect.stringContaining('premiumuser'), + 'premiumuser', + 'premium' + ); + }); + }); + + describe('Error handling in download flow', () => { + it('should handle invalid chain ID', async () => { + const chainRequest = new NextRequest('http://localhost:3000/api/v1/chains/invalid-chain'); + const chainParams = Promise.resolve({ chainId: 'invalid-chain' }); + const chainResponse = await getChainGET(chainRequest, { params: chainParams }); + const chainData = await chainResponse.json(); + + expect(chainResponse.status).toBe(404); + expect(chainData.success).toBe(false); + expect(chainData.error).toBe('Chain not found'); + }); + + it('should handle invalid snapshot ID', async () => { + const downloadRequest = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: '', // Invalid: empty snapshot ID + }), + }); + const downloadParams = Promise.resolve({ chainId: 'cosmos-hub' }); + const downloadResponse = await downloadPOST(downloadRequest, { params: downloadParams }); + const downloadData = await downloadResponse.json(); + + expect(downloadResponse.status).toBe(400); + expect(downloadData.success).toBe(false); + expect(downloadData.error).toBe('Invalid request'); + }); + + it('should handle MinIO service errors', async () => { + mockGetPresignedUrl.mockRejectedValue(new Error('MinIO service unavailable')); + + const downloadRequest = new NextRequest('http://localhost:3000/api/v1/chains/cosmos-hub/download', { + method: 'POST', + body: JSON.stringify({ + snapshotId: 'snapshot-123', + }), + }); + const downloadParams = Promise.resolve({ chainId: 'cosmos-hub' }); + const downloadResponse = await downloadPOST(downloadRequest, { params: downloadParams }); + const downloadData = await downloadResponse.json(); + + expect(downloadResponse.status).toBe(500); + expect(downloadData.success).toBe(false); + expect(downloadData.error).toBe('Failed to generate download URL'); + expect(downloadData.message).toBe('MinIO service unavailable'); + }); + }); + + describe('Concurrent download requests', () => { + it('should handle multiple download requests from same user', async () => { + const chainId = 'cosmos-hub'; + const requests = []; + + // Make 3 concurrent download requests + for (let i = 0; i < 3; i++) { + const downloadRequest = new NextRequest(`http://localhost:3000/api/v1/chains/${chainId}/download`, { + method: 'POST', + body: JSON.stringify({ + snapshotId: `snapshot-${i}`, + }), + }); + const downloadParams = Promise.resolve({ chainId }); + requests.push(downloadPOST(downloadRequest, { params: downloadParams })); + } + + const responses = await Promise.all(requests); + const data = await Promise.all(responses.map(r => r.json())); + + // All requests should succeed + expect(responses.every(r => r.status === 200)).toBe(true); + expect(data.every(d => d.success)).toBe(true); + + // Verify all connections were tracked + expect(mockBandwidthManager.startConnection).toHaveBeenCalledTimes(3); + }); + + it('should track bandwidth across multiple downloads', async () => { + // Simulate bandwidth usage increasing with each download + let totalBandwidth = 0; + mockBandwidthManager.getUserBandwidth.mockImplementation(() => { + totalBandwidth += 1024 * 1024 * 100; // 100 MB per download + return totalBandwidth; + }); + + const chainId = 'cosmos-hub'; + + // First download + const download1Request = new NextRequest(`http://localhost:3000/api/v1/chains/${chainId}/download`, { + method: 'POST', + body: JSON.stringify({ snapshotId: 'snapshot-1' }), + }); + const download1Params = Promise.resolve({ chainId }); + await downloadPOST(download1Request, { params: download1Params }); + + // Second download + const download2Request = new NextRequest(`http://localhost:3000/api/v1/chains/${chainId}/download`, { + method: 'POST', + body: JSON.stringify({ snapshotId: 'snapshot-2' }), + }); + const download2Params = Promise.resolve({ chainId }); + await downloadPOST(download2Request, { params: download2Params }); + + // Verify bandwidth tracking + expect(mockBandwidthManager.getUserBandwidth).toHaveBeenCalled(); + expect(mockBandwidthManager.startConnection).toHaveBeenCalledTimes(2); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/lib/auth/session.test.ts b/__tests__/lib/auth/session.test.ts new file mode 100644 index 0000000..afb4332 --- /dev/null +++ b/__tests__/lib/auth/session.test.ts @@ -0,0 +1,150 @@ +import { getSession, login, logout, getUser, sessionOptions } from '@/lib/auth/session'; +import { getIronSession } from 'iron-session'; +import { cookies } from 'next/headers'; +import { User } from '@/lib/types'; + +// Mock dependencies +jest.mock('iron-session'); +jest.mock('next/headers', () => ({ + cookies: jest.fn(), +})); + +describe('Session Management', () => { + let mockCookies: any; + let mockSession: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock session + mockSession = { + user: undefined, + isLoggedIn: false, + save: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + }; + + // Setup mock cookies + mockCookies = { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + + (cookies as jest.Mock).mockResolvedValue(mockCookies); + (getIronSession as jest.Mock).mockResolvedValue(mockSession); + }); + + describe('getSession', () => { + it('should return a session instance', async () => { + const session = await getSession(); + + expect(cookies).toHaveBeenCalled(); + expect(getIronSession).toHaveBeenCalledWith(mockCookies, sessionOptions); + expect(session).toBe(mockSession); + }); + }); + + describe('login', () => { + it('should set user data and save session', async () => { + const user: User = { + id: '1', + email: 'test@example.com', + name: 'Test User', + role: 'user', + }; + + await login(user); + + expect(mockSession.user).toEqual(user); + expect(mockSession.isLoggedIn).toBe(true); + expect(mockSession.save).toHaveBeenCalled(); + }); + + it('should handle admin users', async () => { + const adminUser: User = { + id: '2', + email: 'admin@example.com', + name: 'Admin User', + role: 'admin', + }; + + await login(adminUser); + + expect(mockSession.user).toEqual(adminUser); + expect(mockSession.user.role).toBe('admin'); + expect(mockSession.isLoggedIn).toBe(true); + }); + }); + + describe('logout', () => { + it('should destroy the session', async () => { + await logout(); + + expect(mockSession.destroy).toHaveBeenCalled(); + }); + + it('should clear user data when session has user', async () => { + mockSession.user = { id: '1', email: 'test@example.com' }; + mockSession.isLoggedIn = true; + + await logout(); + + expect(mockSession.destroy).toHaveBeenCalled(); + }); + }); + + describe('getUser', () => { + it('should return user when logged in', async () => { + const user: User = { + id: '1', + email: 'test@example.com', + name: 'Test User', + role: 'user', + }; + + mockSession.isLoggedIn = true; + mockSession.user = user; + + const result = await getUser(); + + expect(result).toEqual(user); + }); + + it('should return null when not logged in', async () => { + mockSession.isLoggedIn = false; + + const result = await getUser(); + + expect(result).toBeNull(); + }); + + it('should return null when user data is missing', async () => { + mockSession.isLoggedIn = true; + mockSession.user = undefined; + + const result = await getUser(); + + expect(result).toBeNull(); + }); + + it('should return null when session is partially invalid', async () => { + mockSession.isLoggedIn = false; + mockSession.user = { id: '1', email: 'test@example.com' }; + + const result = await getUser(); + + expect(result).toBeNull(); + }); + }); + + describe('sessionOptions', () => { + it('should have required configuration', () => { + expect(sessionOptions).toHaveProperty('password'); + expect(sessionOptions).toHaveProperty('cookieName'); + expect(sessionOptions).toHaveProperty('cookieOptions'); + expect(sessionOptions.password).toBeTruthy(); + expect(sessionOptions.cookieName).toBeTruthy(); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/lib/bandwidth/manager.test.ts b/__tests__/lib/bandwidth/manager.test.ts new file mode 100644 index 0000000..5a394d2 --- /dev/null +++ b/__tests__/lib/bandwidth/manager.test.ts @@ -0,0 +1,214 @@ +import { bandwidthManager } from '@/lib/bandwidth/manager'; +import * as metrics from '@/lib/monitoring/metrics'; + +// Mock monitoring metrics +jest.mock('@/lib/monitoring/metrics'); + +describe('BandwidthManager', () => { + let mockUpdateBandwidthUsage: jest.Mock; + let mockUpdateActiveConnections: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Reset bandwidth manager state + bandwidthManager.resetMonthlyUsage(); + + // Setup mocks + mockUpdateBandwidthUsage = jest.fn(); + mockUpdateActiveConnections = jest.fn(); + + (metrics.updateBandwidthUsage as jest.Mock) = mockUpdateBandwidthUsage; + (metrics.updateActiveConnections as jest.Mock) = mockUpdateActiveConnections; + }); + + describe('startConnection', () => { + it('should start tracking a new connection', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + + const stats = bandwidthManager.getConnectionStats('conn-1'); + expect(stats).toBeDefined(); + expect(stats?.userId).toBe('user-1'); + expect(stats?.tier).toBe('free'); + expect(stats?.bytesTransferred).toBe(0); + expect(stats?.startTime).toBeLessThanOrEqual(Date.now()); + }); + + it('should update connection metrics', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + + expect(mockUpdateActiveConnections).toHaveBeenCalledWith('free', 1); + expect(mockUpdateActiveConnections).toHaveBeenCalledWith('premium', 0); + }); + + it('should handle multiple connections', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.startConnection('conn-2', 'user-2', 'premium'); + bandwidthManager.startConnection('conn-3', 'user-1', 'free'); + + const stats = bandwidthManager.getStats(); + expect(stats.activeConnections).toBe(3); + expect(stats.connectionsByTier.free).toBe(2); + expect(stats.connectionsByTier.premium).toBe(1); + }); + }); + + describe('updateConnection', () => { + it('should update bytes transferred for a connection', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.updateConnection('conn-1', 1024); + + const stats = bandwidthManager.getConnectionStats('conn-1'); + expect(stats?.bytesTransferred).toBe(1024); + }); + + it('should accumulate bandwidth usage for user', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.updateConnection('conn-1', 1024); + bandwidthManager.updateConnection('conn-1', 2048); + + const usage = bandwidthManager.getUserBandwidth('user-1'); + expect(usage).toBe(3072); + }); + + it('should update bandwidth metrics', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'premium'); + bandwidthManager.updateConnection('conn-1', 5000); + + expect(mockUpdateBandwidthUsage).toHaveBeenCalledWith('premium', 'user-1', 5000); + }); + + it('should handle non-existent connection gracefully', () => { + bandwidthManager.updateConnection('non-existent', 1024); + // Should not throw error + expect(mockUpdateBandwidthUsage).not.toHaveBeenCalled(); + }); + }); + + describe('endConnection', () => { + it('should remove connection from tracking', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.endConnection('conn-1'); + + const stats = bandwidthManager.getConnectionStats('conn-1'); + expect(stats).toBeUndefined(); + }); + + it('should update connection count metrics', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.startConnection('conn-2', 'user-2', 'free'); + + mockUpdateActiveConnections.mockClear(); + bandwidthManager.endConnection('conn-1'); + + expect(mockUpdateActiveConnections).toHaveBeenCalledWith('free', 1); + expect(mockUpdateActiveConnections).toHaveBeenCalledWith('premium', 0); + }); + }); + + describe('hasExceededLimit', () => { + it('should return false when under limit', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.updateConnection('conn-1', 1024 * 1024); // 1 MB + + const exceeded = bandwidthManager.hasExceededLimit('user-1', 'free'); + expect(exceeded).toBe(false); + }); + + it('should return true when limit exceeded', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + // Free tier limit is 5 GB + bandwidthManager.updateConnection('conn-1', 6 * 1024 * 1024 * 1024); + + const exceeded = bandwidthManager.hasExceededLimit('user-1', 'free'); + expect(exceeded).toBe(true); + }); + + it('should use correct limits for premium tier', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'premium'); + // Premium tier limit is 100 GB + bandwidthManager.updateConnection('conn-1', 50 * 1024 * 1024 * 1024); + + const exceeded = bandwidthManager.hasExceededLimit('user-1', 'premium'); + expect(exceeded).toBe(false); + }); + }); + + describe('getAvailableBandwidth', () => { + it('should return full bandwidth when no active connections', () => { + const bandwidth = bandwidthManager.getAvailableBandwidth('user-1', 'free'); + expect(bandwidth).toBe(1024 * 1024); // 1 MB/s + }); + + it('should divide bandwidth among active connections', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.startConnection('conn-2', 'user-1', 'free'); + + const bandwidth = bandwidthManager.getAvailableBandwidth('user-1', 'free'); + expect(bandwidth).toBe(512 * 1024); // 512 KB/s per connection + }); + + it('should return correct bandwidth for premium tier', () => { + const bandwidth = bandwidthManager.getAvailableBandwidth('user-1', 'premium'); + expect(bandwidth).toBe(10 * 1024 * 1024); // 10 MB/s + }); + }); + + describe('getUserConnections', () => { + it('should return all connections for a user', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.startConnection('conn-2', 'user-2', 'free'); + bandwidthManager.startConnection('conn-3', 'user-1', 'free'); + + const connections = bandwidthManager.getUserConnections('user-1'); + expect(connections).toHaveLength(2); + expect(connections.every(c => c.userId === 'user-1')).toBe(true); + }); + + it('should return empty array for user with no connections', () => { + const connections = bandwidthManager.getUserConnections('user-999'); + expect(connections).toHaveLength(0); + }); + }); + + describe('resetMonthlyUsage', () => { + it('should reset all bandwidth usage', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.updateConnection('conn-1', 1024 * 1024); + + bandwidthManager.resetMonthlyUsage(); + + const usage = bandwidthManager.getUserBandwidth('user-1'); + expect(usage).toBe(0); + }); + + it('should update metrics for active connections', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.startConnection('conn-2', 'user-2', 'premium'); + + mockUpdateBandwidthUsage.mockClear(); + bandwidthManager.resetMonthlyUsage(); + + expect(mockUpdateBandwidthUsage).toHaveBeenCalledWith('free', 'user-1', 0); + expect(mockUpdateBandwidthUsage).toHaveBeenCalledWith('premium', 'user-2', 0); + }); + }); + + describe('getStats', () => { + it('should return comprehensive statistics', () => { + bandwidthManager.startConnection('conn-1', 'user-1', 'free'); + bandwidthManager.startConnection('conn-2', 'user-2', 'premium'); + bandwidthManager.updateConnection('conn-1', 1000); + bandwidthManager.updateConnection('conn-2', 2000); + + const stats = bandwidthManager.getStats(); + + expect(stats).toEqual({ + activeConnections: 2, + connectionsByTier: { free: 1, premium: 1 }, + totalBandwidthUsage: 3000, + userCount: 2, + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/lib/middleware/rateLimiter.test.ts b/__tests__/lib/middleware/rateLimiter.test.ts new file mode 100644 index 0000000..312e2f3 --- /dev/null +++ b/__tests__/lib/middleware/rateLimiter.test.ts @@ -0,0 +1,233 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { rateLimitMiddleware, withRateLimit } from '@/lib/middleware/rateLimiter'; +import { RateLimiterMemory } from 'rate-limiter-flexible'; +import * as metrics from '@/lib/monitoring/metrics'; +import { getIronSession } from 'iron-session'; + +// Mock dependencies +jest.mock('rate-limiter-flexible'); +jest.mock('@/lib/monitoring/metrics'); +jest.mock('iron-session'); +jest.mock('next/headers', () => ({ + cookies: jest.fn().mockResolvedValue({ + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }), +})); + +describe('Rate Limiter', () => { + let mockConsume: jest.Mock; + let mockTrackRateLimitHit: jest.Mock; + let mockGetIronSession: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mocks + mockConsume = jest.fn().mockResolvedValue(undefined); + mockTrackRateLimitHit = jest.fn(); + mockGetIronSession = jest.fn().mockResolvedValue(null); + + // Mock RateLimiterMemory constructor + (RateLimiterMemory as jest.Mock).mockImplementation(() => ({ + consume: mockConsume, + points: 100, + })); + + (metrics.trackRateLimitHit as jest.Mock) = mockTrackRateLimitHit; + (getIronSession as jest.Mock) = mockGetIronSession; + }); + + describe('rateLimitMiddleware', () => { + it('should allow request when under rate limit', async () => { + const request = new NextRequest('http://localhost:3000/api/test'); + + const response = await rateLimitMiddleware(request); + + expect(response).toBeNull(); + expect(mockConsume).toHaveBeenCalledWith('anonymous'); + }); + + it('should use user ID for authenticated users', async () => { + mockGetIronSession.mockResolvedValue({ + username: 'user123', + tier: 'free', + }); + + const request = new NextRequest('http://localhost:3000/api/test'); + + await rateLimitMiddleware(request); + + expect(mockConsume).toHaveBeenCalledWith('user123'); + }); + + it('should block request when rate limit exceeded', async () => { + const rateLimitError = { + msBeforeNext: 60000, + remainingPoints: 0, + }; + mockConsume.mockRejectedValue(rateLimitError); + + const request = new NextRequest('http://localhost:3000/api/test'); + + const response = await rateLimitMiddleware(request); + + expect(response).not.toBeNull(); + expect(response?.status).toBe(429); + + const data = await response?.json(); + expect(data.error).toBe('Too many requests'); + expect(data.retryAfter).toBe(60); + expect(data.message).toBe('Please try again in 60 seconds'); + }); + + it('should set rate limit headers', async () => { + const rateLimitError = { + msBeforeNext: 30000, + remainingPoints: 5, + }; + mockConsume.mockRejectedValue(rateLimitError); + + const request = new NextRequest('http://localhost:3000/api/test'); + + const response = await rateLimitMiddleware(request); + + expect(response?.headers.get('Retry-After')).toBe('30'); + expect(response?.headers.get('X-RateLimit-Limit')).toBe('100'); + expect(response?.headers.get('X-RateLimit-Remaining')).toBe('5'); + expect(response?.headers.get('X-RateLimit-Reset')).toBeTruthy(); + }); + + it('should track rate limit hits', async () => { + mockConsume.mockRejectedValue({ msBeforeNext: 60000 }); + + const request = new NextRequest('http://localhost:3000/api/test'); + + await rateLimitMiddleware(request); + + expect(mockTrackRateLimitHit).toHaveBeenCalledWith('/api/test', 'free'); + }); + + it('should use premium tier for premium users', async () => { + mockGetIronSession.mockResolvedValue({ + username: 'premium-user', + tier: 'premium', + }); + + const request = new NextRequest('http://localhost:3000/api/test'); + + await rateLimitMiddleware(request, 'general'); + + // Verify premium rate limiter was created (200 points instead of 100) + const rateLimiterCalls = (RateLimiterMemory as jest.Mock).mock.calls; + const premiumLimiter = rateLimiterCalls.find(call => call[0].points === 200); + expect(premiumLimiter).toBeDefined(); + }); + + it('should use different rate limiters for different types', async () => { + const request = new NextRequest('http://localhost:3000/api/test'); + + await rateLimitMiddleware(request, 'download'); + await rateLimitMiddleware(request, 'auth'); + await rateLimitMiddleware(request, 'general'); + + const rateLimiterCalls = (RateLimiterMemory as jest.Mock).mock.calls; + + // Verify different configurations were used + const downloadLimiter = rateLimiterCalls.find(call => call[0].points === 10); + const authLimiter = rateLimiterCalls.find(call => call[0].points === 5); + const generalLimiter = rateLimiterCalls.find(call => call[0].points === 100); + + expect(downloadLimiter).toBeDefined(); + expect(authLimiter).toBeDefined(); + expect(generalLimiter).toBeDefined(); + }); + + it('should handle rate limiter errors gracefully', async () => { + mockConsume.mockRejectedValue(new Error('Rate limiter failed')); + + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + const request = new NextRequest('http://localhost:3000/api/test'); + + const response = await rateLimitMiddleware(request); + + expect(response).toBeNull(); // Allow request on error + expect(consoleError).toHaveBeenCalledWith('Rate limiter error:', expect.any(Error)); + + consoleError.mockRestore(); + }); + + it('should use IP fallback when no user session', async () => { + const request = new NextRequest('http://localhost:3000/api/test', { + headers: { + 'x-forwarded-for': '192.168.1.1', + }, + }); + + await rateLimitMiddleware(request); + + expect(mockConsume).toHaveBeenCalledWith('192.168.1.1'); + }); + + it('should use x-real-ip header as fallback', async () => { + const request = new NextRequest('http://localhost:3000/api/test', { + headers: { + 'x-real-ip': '10.0.0.1', + }, + }); + + await rateLimitMiddleware(request); + + expect(mockConsume).toHaveBeenCalledWith('10.0.0.1'); + }); + }); + + describe('withRateLimit', () => { + it('should wrap handler and apply rate limiting', async () => { + const mockHandler = jest.fn().mockResolvedValue( + NextResponse.json({ success: true }) + ); + + const rateLimitedHandler = withRateLimit(mockHandler, 'download'); + const request = new NextRequest('http://localhost:3000/api/test'); + + const response = await rateLimitedHandler(request); + + expect(mockConsume).toHaveBeenCalled(); + expect(mockHandler).toHaveBeenCalledWith(request); + + const data = await response.json(); + expect(data.success).toBe(true); + }); + + it('should return rate limit response when limit exceeded', async () => { + mockConsume.mockRejectedValue({ msBeforeNext: 60000 }); + + const mockHandler = jest.fn(); + const rateLimitedHandler = withRateLimit(mockHandler, 'auth'); + const request = new NextRequest('http://localhost:3000/api/test'); + + const response = await rateLimitedHandler(request); + + expect(response.status).toBe(429); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('should use default rate limit type', async () => { + const mockHandler = jest.fn().mockResolvedValue( + NextResponse.json({ success: true }) + ); + + const rateLimitedHandler = withRateLimit(mockHandler); + const request = new NextRequest('http://localhost:3000/api/test'); + + await rateLimitedHandler(request); + + // Should use general rate limiter (100 points) + const rateLimiterCalls = (RateLimiterMemory as jest.Mock).mock.calls; + const generalLimiter = rateLimiterCalls.find(call => call[0].points === 100); + expect(generalLimiter).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/app/(admin)/dashboard/page.tsx b/app/(admin)/dashboard/page.tsx new file mode 100644 index 0000000..355bec8 --- /dev/null +++ b/app/(admin)/dashboard/page.tsx @@ -0,0 +1,40 @@ +import { getStats } from '@/lib/bandwidth/stats'; +import { AdminStats } from '@/components/admin/AdminStats'; +import { BandwidthChart } from '@/components/admin/BandwidthChart'; +import { ActiveConnections } from '@/components/admin/ActiveConnections'; + +export default async function AdminDashboard() { + const stats = await getStats(); + + return ( +
+
+

+ Admin Dashboard +

+

+ Monitor bandwidth usage, active connections, and system health. +

+
+ + {/* Quick stats */} + + + {/* Bandwidth usage chart */} +
+ + +
+ + {/* Recent downloads */} +
+

+ Recent Downloads +

+

+ Download history will be displayed here once implemented. +

+
+
+ ); +} \ No newline at end of file diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx new file mode 100644 index 0000000..e35737a --- /dev/null +++ b/app/(admin)/layout.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react'; +import { redirect } from 'next/navigation'; +import { getUser } from '@/lib/auth/session'; + +export default async function AdminLayout({ children }: { children: ReactNode }) { + const user = await getUser(); + + // Admin routes require authentication + if (!user) { + redirect('/login'); + } + + return ( +
+ {/* Admin header */} +
+
+
+

+ Admin Dashboard +

+ + Logged in as: {user.email} + +
+
+
+ + {/* Admin content */} +
+ {children} +
+
+ ); +} \ No newline at end of file diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..83cc85f --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+ {/* Auth routes have a different layout without the main header */} + {children} +
+ ); +} \ No newline at end of file diff --git a/app/(auth)/login/error.tsx b/app/(auth)/login/error.tsx new file mode 100644 index 0000000..24067f6 --- /dev/null +++ b/app/(auth)/login/error.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; + +export default function LoginError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('Login error:', error); + }, [error]); + + return ( +
+
+ {/* Error icon */} +
+ + + +
+ + {/* Error message */} +
+

+ Authentication Error +

+

+ We encountered an issue with the login process. Please try again. +

+
+ + {/* Error details if available */} + {error.message && error.message.toLowerCase().includes('auth') && ( +
+

+ This might be due to invalid credentials or a session timeout. Please ensure your username and password are correct. +

+
+ )} + + {/* Actions */} +
+ + + Return to home + +
+ + {/* Help text */} +

+ Need help? Contact{' '} + + support@bryanlabs.net + +

+
+
+ ); +} \ No newline at end of file diff --git a/app/(auth)/login/loading.tsx b/app/(auth)/login/loading.tsx new file mode 100644 index 0000000..149b2b9 --- /dev/null +++ b/app/(auth)/login/loading.tsx @@ -0,0 +1,46 @@ +export default function LoginLoading() { + return ( +
+
+ {/* Logo/Header skeleton */} +
+
+
+
+
+ + {/* Form skeleton */} +
+
+ {/* Username field */} +
+
+
+
+ + {/* Password field */} +
+
+
+
+ + {/* Remember me */} +
+
+
+
+ + {/* Submit button */} +
+
+ + {/* Additional info */} +
+
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..8d5b4f7 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,35 @@ +import { LoginForm } from '@/components/auth/LoginForm'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Login', + description: 'Sign in to access premium features', +}; + +export default function LoginPage() { + return ( +
+
+
+

+ BryanLabs Snapshots +

+

+ Sign in to access premium features +

+
+ + + +
+

+ Don't have an account?{' '} + + Contact us + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/app/(public)/chains/[chainId]/error.tsx b/app/(public)/chains/[chainId]/error.tsx new file mode 100644 index 0000000..d4ac18b --- /dev/null +++ b/app/(public)/chains/[chainId]/error.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +export default function ChainDetailError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const params = useParams(); + const chainId = params?.chainId as string; + + useEffect(() => { + console.error('Chain detail error:', error); + }, [error]); + + return ( +
+
+ {/* Error icon */} +
+ + + +
+ + {/* Error message */} +
+

+ Snapshot not found +

+

+ We couldn't find snapshots for {chainId}. + The chain might not be available or there could be a temporary issue. +

+ {error.message && error.message !== 'Failed to fetch' && ( +
+ + Technical details + +
+                {error.message}
+              
+
+ )} +
+ + {/* Actions */} +
+ + + Browse all chains + +
+ + {/* Suggestions */} +
+

+ Looking for a specific chain? Try these popular options: +

+
+ {['cosmos', 'osmosis', 'juno', 'stargaze', 'akash'].map((chain) => ( + + {chain} + + ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/(public)/chains/[chainId]/loading.tsx b/app/(public)/chains/[chainId]/loading.tsx new file mode 100644 index 0000000..5f7c6da --- /dev/null +++ b/app/(public)/chains/[chainId]/loading.tsx @@ -0,0 +1,72 @@ +export default function ChainDetailLoading() { + return ( +
+ {/* Breadcrumb skeleton */} +
+
+
+
+
+ + {/* Chain header skeleton */} +
+
+
+
+
+
+
+
+ + {/* Chain info grid */} +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+ + {/* Snapshots table skeleton */} +
+ {/* Table header */} +
+
+
+ + {/* Table rows */} +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* Pagination skeleton */} +
+
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/chains/[chainId]/not-found.tsx b/app/(public)/chains/[chainId]/not-found.tsx similarity index 100% rename from app/chains/[chainId]/not-found.tsx rename to app/(public)/chains/[chainId]/not-found.tsx diff --git a/app/(public)/chains/[chainId]/page-complex.tsx b/app/(public)/chains/[chainId]/page-complex.tsx new file mode 100644 index 0000000..fd69b05 --- /dev/null +++ b/app/(public)/chains/[chainId]/page-complex.tsx @@ -0,0 +1,143 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import Image from 'next/image'; +import { SnapshotListServer } from '@/components/snapshots/SnapshotListServer'; +import type { Metadata } from 'next'; +import { Suspense } from 'react'; + +async function getChain(chainId: string) { + try { + // Use the internal API URL for server-side requests + const apiUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + const response = await fetch(`${apiUrl}/api/v1/chains/${chainId}`, { + cache: 'no-store' + }); + + if (!response.ok) { + return null; + } + + const data = await response.json(); + return data.success ? data.data : null; + } catch (error) { + console.error('Failed to fetch chain:', error); + return null; + } +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ chainId: string }>; +}): Promise { + const { chainId } = await params; + const chain = await getChain(chainId); + + if (!chain) { + return { + title: 'Chain Not Found', + }; + } + + return { + title: chain.name, + description: `Download blockchain snapshots for ${chain.name}. Fast, reliable snapshots updated daily with pruned options available.`, + }; +} + +export default async function ChainDetailPage({ + params, +}: { + params: Promise<{ chainId: string }>; +}) { + const { chainId } = await params; + const chain = await getChain(chainId); + + if (!chain) { + notFound(); + } + + return ( +
+ {/* Breadcrumb */} +
+
+ +
+
+ + {/* Header */} +
+
+
+ {chain.logoUrl && ( +
+ {`${chain.name} +
+ )} +
+

+ {chain.name} +

+

+ {chain.network} +

+ {chain.description && ( +

+ {chain.description} +

+ )} +
+
+
+
+ + {/* Snapshots Section */} +
+
+
+

+ Available Snapshots +

+

+ Download the latest blockchain snapshots for {chain.name} +

+
+ + + {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ } + > + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/(public)/chains/[chainId]/page.tsx b/app/(public)/chains/[chainId]/page.tsx new file mode 100644 index 0000000..61dcf96 --- /dev/null +++ b/app/(public)/chains/[chainId]/page.tsx @@ -0,0 +1,99 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { mockChains, mockSnapshots } from '@/lib/mock-data'; +import { SnapshotListClient } from '@/components/snapshots/SnapshotListClient'; +import type { Metadata } from 'next'; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ chainId: string }>; +}): Promise { + const { chainId } = await params; + const chain = mockChains[chainId as keyof typeof mockChains]; + + if (!chain) { + return { + title: 'Chain Not Found', + }; + } + + return { + title: chain.name, + description: `Download blockchain snapshots for ${chain.name}. Fast, reliable snapshots updated daily with pruned options available.`, + }; +} + +export default async function ChainDetailPage({ + params, +}: { + params: Promise<{ chainId: string }>; +}) { + const { chainId } = await params; + const chain = mockChains[chainId as keyof typeof mockChains]; + const snapshots = mockSnapshots[chainId as keyof typeof mockSnapshots] || []; + + if (!chain) { + notFound(); + } + + return ( +
+ {/* Breadcrumb */} +
+
+ +
+
+ + {/* Header */} +
+
+
+
+

+ {chain.name} +

+

+ {chain.network} +

+ {chain.description && ( +

+ {chain.description} +

+ )} +
+
+
+
+ + {/* Snapshots Section */} +
+
+
+

+ Available Snapshots +

+

+ Download the latest blockchain snapshots for {chain.name} +

+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/(public)/chains/error.tsx b/app/(public)/chains/error.tsx new file mode 100644 index 0000000..4391887 --- /dev/null +++ b/app/(public)/chains/error.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; + +export default function ChainsError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error('Chains page error:', error); + }, [error]); + + return ( +
+
+ {/* Error icon */} +
+ + + +
+ + {/* Error message */} +
+

+ Failed to load chains +

+

+ We couldn't fetch the list of available chains. This might be a temporary issue. +

+ {error.message && ( +

+ {error.message} +

+ )} +
+ + {/* Actions */} +
+ + + Back to home + +
+ + {/* Help text */} +

+ If you continue to experience issues, please check our{' '} + + status page + + {' '}or contact support. +

+
+
+ ); +} \ No newline at end of file diff --git a/app/(public)/chains/loading.tsx b/app/(public)/chains/loading.tsx new file mode 100644 index 0000000..9345470 --- /dev/null +++ b/app/(public)/chains/loading.tsx @@ -0,0 +1,53 @@ +export default function ChainsLoading() { + return ( +
+ {/* Page header skeleton */} +
+
+
+
+ + {/* Filter/Search skeleton */} +
+
+
+
+ + {/* Chain grid skeleton */} +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => ( +
+
+ {/* Chain icon and name */} +
+
+
+
+
+
+
+ + {/* Chain stats */} +
+
+
+
+
+
+
+
+
+
+ + {/* Action button */} +
+
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/app/(public)/layout.tsx b/app/(public)/layout.tsx new file mode 100644 index 0000000..bcc6418 --- /dev/null +++ b/app/(public)/layout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; + +export default function PublicLayout({ children }: { children: ReactNode }) { + return ( + <> + {/* Public routes don't require authentication */} + {children} + + ); +} \ No newline at end of file diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts new file mode 100644 index 0000000..3e4d5bc --- /dev/null +++ b/app/api/admin/stats/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { bandwidthManager } from '@/lib/bandwidth/manager'; +import { register } from '@/lib/monitoring/metrics'; +import { getIronSession } from 'iron-session'; +import { User } from '@/types/user'; +import { sessionOptions } from '@/lib/session'; +import { cookies } from 'next/headers'; + +/** + * Admin endpoint to view system statistics + * Requires admin authentication + */ +async function handleGetStats(request: NextRequest) { + // Check authentication + const cookieStore = await cookies(); + const session = await getIronSession(cookieStore, sessionOptions); + + // For now, just check if logged in - you might want to add admin role check + if (!session?.isLoggedIn) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + try { + // Get bandwidth statistics + const bandwidthStats = bandwidthManager.getStats(); + + // Get current metrics snapshot + const metricsText = await register.metrics(); + const metrics = parseMetrics(metricsText); + + return NextResponse.json({ + success: true, + data: { + bandwidth: bandwidthStats, + metrics: metrics, + timestamp: new Date().toISOString(), + }, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Failed to fetch statistics', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} + +// Helper function to parse Prometheus metrics into JSON +function parseMetrics(metricsText: string): Record { + const lines = metricsText.split('\n'); + const metrics: Record = {}; + + for (const line of lines) { + if (line.startsWith('#') || !line.trim()) continue; + + const match = line.match(/^([^\s{]+)({[^}]+})?\s+(.+)$/); + if (match) { + const [, name, labels, value] = match; + if (!metrics[name]) { + metrics[name] = []; + } + metrics[name].push({ + labels: labels ? JSON.parse(labels.replace(/([a-zA-Z_]+)=/g, '"$1":')) : {}, + value: parseFloat(value), + }); + } + } + + return metrics; +} + +export const GET = handleGetStats; \ No newline at end of file diff --git a/app/api/bandwidth/status/route.ts b/app/api/bandwidth/status/route.ts new file mode 100644 index 0000000..80b0dfa --- /dev/null +++ b/app/api/bandwidth/status/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { bandwidthManager } from '@/lib/bandwidth/manager'; +import { getUser } from '@/lib/auth/session'; + +export async function GET() { + try { + const user = await getUser(); + const tier = user ? 'premium' : 'free'; + const stats = bandwidthManager.getStats(); + + // Calculate current speed based on active connections + const tierConnections = tier === 'premium' + ? stats.connectionsByTier.premium + : stats.connectionsByTier.free; + + const maxSpeed = tier === 'premium' ? 250 : 50; + const currentSpeed = tierConnections > 0 ? maxSpeed / tierConnections : 0; + + return NextResponse.json({ + tier, + currentSpeed, + maxSpeed, + activeConnections: tierConnections, + totalActiveConnections: stats.activeConnections, + }); + } catch (error) { + console.error('Failed to get bandwidth status:', error); + return NextResponse.json( + { error: 'Failed to get bandwidth status' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/cron/reset-bandwidth/route.ts b/app/api/cron/reset-bandwidth/route.ts new file mode 100644 index 0000000..e1fdb5b --- /dev/null +++ b/app/api/cron/reset-bandwidth/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { monthlyBandwidthResetTask } from '@/lib/tasks/resetBandwidth'; +import { headers } from 'next/headers'; + +/** + * API endpoint for resetting monthly bandwidth + * This can be called by Vercel Cron Jobs + * + * Add to vercel.json: + * { + * "crons": [{ + * "path": "/api/cron/reset-bandwidth", + * "schedule": "0 0 1 * *" + * }] + * } + */ +export async function GET(request: NextRequest) { + try { + // Verify the request is from Vercel Cron + const authHeader = (await headers()).get('authorization'); + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + await monthlyBandwidthResetTask(); + + return NextResponse.json({ + success: true, + message: 'Monthly bandwidth reset completed', + timestamp: new Date().toISOString(), + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Failed to reset bandwidth', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..c45d453 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import { ApiResponse, HealthCheckResponse } from '@/lib/types'; +import { getMinioClient } from '@/lib/minio/client'; + +export async function GET() { + try { + // Check MinIO connection + let minioHealthy = false; + try { + const client = getMinioClient(); + await client.listBuckets(); + minioHealthy = true; + } catch (error) { + console.error('MinIO health check failed:', error); + } + + const response: HealthCheckResponse = { + status: minioHealthy ? 'healthy' : 'unhealthy', + timestamp: new Date().toISOString(), + services: { + database: true, // Placeholder - implement actual database check + minio: minioHealthy, + }, + }; + + return NextResponse.json>({ + success: true, + data: response, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Health check failed', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/metrics/route.ts b/app/api/metrics/route.ts new file mode 100644 index 0000000..a1510bb --- /dev/null +++ b/app/api/metrics/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { register } from '@/lib/monitoring/metrics'; +import { getIronSession } from 'iron-session'; +import { User } from '@/types/user'; +import { sessionOptions } from '@/lib/session'; +import { cookies } from 'next/headers'; + +export async function GET(request: NextRequest) { + try { + // Optional: Add authentication check for metrics endpoint + // You might want to restrict access to metrics + const cookieStore = await cookies(); + const session = await getIronSession(cookieStore, sessionOptions); + + // Uncomment to require authentication for metrics + // if (!session?.isLoggedIn) { + // return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + // } + + // Get metrics in Prometheus format + const metrics = await register.metrics(); + + return new NextResponse(metrics, { + status: 200, + headers: { + 'Content-Type': register.contentType, + }, + }); + } catch (error) { + console.error('Error collecting metrics:', error); + return NextResponse.json( + { error: 'Failed to collect metrics' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/v1/auth/login/route.ts b/app/api/v1/auth/login/route.ts new file mode 100644 index 0000000..d4e3daf --- /dev/null +++ b/app/api/v1/auth/login/route.ts @@ -0,0 +1,153 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ApiResponse, LoginRequest, User } from '@/lib/types'; +import { login } from '@/lib/auth/session'; +import bcrypt from 'bcryptjs'; +import { z } from 'zod'; +import { withRateLimit } from '@/lib/middleware/rateLimiter'; +import { collectResponseTime, trackRequest, trackAuthAttempt } from '@/lib/monitoring/metrics'; +import { logAuth, extractRequestMetadata, logRequest } from '@/lib/middleware/logger'; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(6), +}); + +// Mock user data - replace with actual database queries +const mockUsers = [ + { + id: '1', + email: 'admin@example.com', + password: '$2a$10$YourHashedPasswordHere', // Use bcrypt.hash('password', 10) to generate + name: 'Admin User', + role: 'admin' as const, + }, + { + id: '2', + email: 'user@example.com', + password: '$2a$10$YourHashedPasswordHere', + name: 'Regular User', + role: 'user' as const, + }, +]; + +async function handleLogin(request: NextRequest) { + const endTimer = collectResponseTime('POST', '/api/v1/auth/login'); + const startTime = Date.now(); + const requestLog = extractRequestMetadata(request); + + try { + const body = await request.json(); + + // Validate request body + const validationResult = loginSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid request', + message: validationResult.error.errors.map(e => e.message).join(', '), + }, + { status: 400 } + ); + } + + const { email, password } = validationResult.data; + + // TODO: Implement actual database query + // const user = await db.user.findUnique({ where: { email } }); + + // Mock authentication + const user = mockUsers.find(u => u.email === email); + + if (!user) { + const response = NextResponse.json( + { + success: false, + error: 'Invalid credentials', + message: 'Email or password is incorrect', + }, + { status: 401 } + ); + + endTimer(); + trackRequest('POST', '/api/v1/auth/login', 401); + trackAuthAttempt('login', false); + logAuth('login', email, false, 'Invalid credentials'); + logRequest({ + ...requestLog, + responseStatus: 401, + responseTime: Date.now() - startTime, + error: 'Invalid credentials', + }); + + return response; + } + + // For demo purposes, accept any password + // In production, use: const isValidPassword = await bcrypt.compare(password, user.password); + const isValidPassword = true; + + if (!isValidPassword) { + return NextResponse.json( + { + success: false, + error: 'Invalid credentials', + message: 'Email or password is incorrect', + }, + { status: 401 } + ); + } + + // Create session + const sessionUser: User = { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }; + + await login(sessionUser); + + const response = NextResponse.json>({ + success: true, + data: sessionUser, + message: 'Login successful', + }); + + endTimer(); + trackRequest('POST', '/api/v1/auth/login', 200); + trackAuthAttempt('login', true); + logAuth('login', email, true); + logRequest({ + ...requestLog, + userId: user.id, + responseStatus: 200, + responseTime: Date.now() - startTime, + }); + + return response; + } catch (error) { + const response = NextResponse.json( + { + success: false, + error: 'Login failed', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + + endTimer(); + trackRequest('POST', '/api/v1/auth/login', 500); + logRequest({ + ...requestLog, + responseStatus: 500, + responseTime: Date.now() - startTime, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return response; + } +} + +// Apply rate limiting to the login endpoint +export const POST = withRateLimit(handleLogin, 'auth'); \ No newline at end of file diff --git a/app/api/v1/auth/logout/route.ts b/app/api/v1/auth/logout/route.ts new file mode 100644 index 0000000..4b53c39 --- /dev/null +++ b/app/api/v1/auth/logout/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import { ApiResponse } from '@/lib/types'; +import { logout } from '@/lib/auth/session'; + +export async function POST() { + try { + await logout(); + + return NextResponse.json({ + success: true, + message: 'Logged out successfully', + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Logout failed', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/v1/auth/me/route.ts b/app/api/v1/auth/me/route.ts new file mode 100644 index 0000000..e1fb0cd --- /dev/null +++ b/app/api/v1/auth/me/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { ApiResponse, User } from '@/lib/types'; +import { getUser } from '@/lib/auth/session'; + +export async function GET() { + try { + const user = await getUser(); + + if (!user) { + return NextResponse.json( + { + success: false, + error: 'Not authenticated', + message: 'No active session found', + }, + { status: 401 } + ); + } + + return NextResponse.json>({ + success: true, + data: user, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Failed to get user info', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/v1/chains/[chainId]/download/route.ts b/app/api/v1/chains/[chainId]/download/route.ts new file mode 100644 index 0000000..6af6e88 --- /dev/null +++ b/app/api/v1/chains/[chainId]/download/route.ts @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ApiResponse, DownloadRequest } from '@/lib/types'; +import { getPresignedUrl } from '@/lib/minio/client'; +import { config } from '@/lib/config'; +import { z } from 'zod'; +import { withRateLimit } from '@/lib/middleware/rateLimiter'; +import { collectResponseTime, trackRequest, trackDownload } from '@/lib/monitoring/metrics'; +import { logDownload, extractRequestMetadata, logRequest } from '@/lib/middleware/logger'; +import { bandwidthManager } from '@/lib/bandwidth/manager'; +import { getIronSession } from 'iron-session'; +import { User } from '@/types/user'; +import { sessionOptions } from '@/lib/session'; +import { cookies } from 'next/headers'; + +const downloadRequestSchema = z.object({ + snapshotId: z.string().min(1), + email: z.string().email().optional(), +}); + +async function handleDownload( + request: NextRequest, + { params }: { params: Promise<{ chainId: string }> } +) { + const endTimer = collectResponseTime('POST', '/api/v1/chains/[chainId]/download'); + const startTime = Date.now(); + const requestLog = extractRequestMetadata(request); + + try { + const { chainId } = await params; + const body = await request.json(); + + // Get user session + const cookieStore = await cookies(); + const session = await getIronSession(cookieStore, sessionOptions); + const userId = session?.username || 'anonymous'; + const tier = session?.tier || 'free'; + + // Check bandwidth limits + if (bandwidthManager.hasExceededLimit(userId, tier as 'free' | 'premium')) { + const response = NextResponse.json( + { + success: false, + error: 'Bandwidth limit exceeded', + message: 'You have exceeded your monthly bandwidth limit', + }, + { status: 429 } + ); + + endTimer(); + trackRequest('POST', '/api/v1/chains/[chainId]/download', 429); + logRequest({ + ...requestLog, + userId, + tier, + responseStatus: 429, + responseTime: Date.now() - startTime, + error: 'Bandwidth limit exceeded', + }); + + return response; + } + + // Validate request body + const validationResult = downloadRequestSchema.safeParse(body); + if (!validationResult.success) { + return NextResponse.json( + { + success: false, + error: 'Invalid request', + message: validationResult.error.errors.map(e => e.message).join(', '), + }, + { status: 400 } + ); + } + + const { snapshotId, email } = validationResult.data; + + // TODO: Implement actual database query to get snapshot details + // const snapshot = await db.snapshot.findUnique({ + // where: { id: snapshotId, chainId } + // }); + + // Mock snapshot for demonstration + const snapshot = { + id: snapshotId, + chainId, + fileName: `${chainId}-snapshot.tar.lz4`, + }; + + if (!snapshot) { + return NextResponse.json( + { + success: false, + error: 'Snapshot not found', + message: `Snapshot ${snapshotId} not found for chain ${chainId}`, + }, + { status: 404 } + ); + } + + // Get client IP for restriction + const clientIp = request.headers.get('x-forwarded-for') || + request.headers.get('x-real-ip') || + request.headers.get('cf-connecting-ip') || + 'unknown'; + + // Generate presigned URL for download with metadata and IP restriction + const downloadUrl = await getPresignedUrl( + config.minio.bucketName, + snapshot.fileName, + 300, // 5 minutes expiry as per PRD + { + tier, + ip: clientIp.split(',')[0].trim(), // Use first IP if multiple + userId + } + ); + + // Track download metrics + trackDownload(tier, snapshotId); + + // Log download event + logDownload(userId, snapshotId, tier, true); + + // TODO: Log download request if email provided + if (email) { + // await db.downloadLog.create({ + // data: { + // snapshotId, + // email, + // chainId, + // timestamp: new Date(), + // } + // }); + } + + // Create connection ID for bandwidth tracking + const connectionId = `${userId}-${snapshotId}-${Date.now()}`; + bandwidthManager.startConnection(connectionId, userId, tier as 'free' | 'premium'); + + const response = NextResponse.json>({ + success: true, + data: { downloadUrl }, + message: 'Download URL generated successfully', + }); + + endTimer(); + trackRequest('POST', '/api/v1/chains/[chainId]/download', 200); + logRequest({ + ...requestLog, + userId, + tier, + responseStatus: 200, + responseTime: Date.now() - startTime, + }); + + return response; + } catch (error) { + const response = NextResponse.json( + { + success: false, + error: 'Failed to generate download URL', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + + endTimer(); + trackRequest('POST', '/api/v1/chains/[chainId]/download', 500); + logRequest({ + ...requestLog, + responseStatus: 500, + responseTime: Date.now() - startTime, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return response; + } +} + +// Apply rate limiting to the download endpoint +export const POST = withRateLimit(handleDownload, 'download'); \ No newline at end of file diff --git a/app/api/v1/chains/[chainId]/route.ts b/app/api/v1/chains/[chainId]/route.ts new file mode 100644 index 0000000..14c0ad6 --- /dev/null +++ b/app/api/v1/chains/[chainId]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ApiResponse, Chain } from '@/lib/types'; + +// Mock data - replace with actual database queries +const mockChains: Record = { + 'cosmos-hub': { + id: 'cosmos-hub', + name: 'Cosmos Hub', + network: 'cosmoshub-4', + description: 'The Cosmos Hub is the first of thousands of interconnected blockchains.', + logoUrl: '/chains/cosmos.png', + }, + 'osmosis': { + id: 'osmosis', + name: 'Osmosis', + network: 'osmosis-1', + description: 'Osmosis is an advanced AMM protocol for interchain assets.', + logoUrl: '/chains/osmosis.png', + }, + 'juno': { + id: 'juno', + name: 'Juno', + network: 'juno-1', + description: 'Juno is a sovereign public blockchain in the Cosmos ecosystem.', + logoUrl: '/chains/juno.png', + }, +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ chainId: string }> } +) { + try { + const { chainId } = await params; + + // TODO: Implement actual database query + // const chain = await db.chain.findUnique({ where: { id: chainId } }); + + const chain = mockChains[chainId]; + + if (!chain) { + return NextResponse.json( + { + success: false, + error: 'Chain not found', + message: `Chain with ID ${chainId} not found`, + }, + { status: 404 } + ); + } + + return NextResponse.json>({ + success: true, + data: chain, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Failed to fetch chain', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/v1/chains/[chainId]/snapshots/route.ts b/app/api/v1/chains/[chainId]/snapshots/route.ts new file mode 100644 index 0000000..3fb315b --- /dev/null +++ b/app/api/v1/chains/[chainId]/snapshots/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ApiResponse, Snapshot } from '@/lib/types'; + +// Mock data - replace with actual database queries +const mockSnapshots: Record = { + 'cosmos-hub': [ + { + id: 'cosmos-snapshot-1', + chainId: 'cosmos-hub', + height: 19234567, + size: 450 * 1024 * 1024 * 1024, // 450 GB in bytes + fileName: 'cosmoshub-4-15234567.tar.lz4', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + type: 'pruned', + compressionType: 'lz4', + }, + { + id: 'cosmos-snapshot-2', + chainId: 'cosmos-hub', + height: 19200000, + size: 850 * 1024 * 1024 * 1024, // 850 GB in bytes + fileName: 'cosmoshub-4-15200000.tar.lz4', + createdAt: new Date('2024-01-10'), + updatedAt: new Date('2024-01-10'), + type: 'archive', + compressionType: 'lz4', + }, + ], + 'osmosis': [ + { + id: 'osmosis-snapshot-1', + chainId: 'osmosis', + height: 12345678, + size: 128849018880, // ~120 GB in bytes + fileName: 'osmosis-1-12345678.tar.lz4', + createdAt: new Date('2024-01-10'), + updatedAt: new Date('2024-01-10'), + type: 'pruned', + compressionType: 'lz4', + }, + { + id: 'osmosis-snapshot-2', + chainId: 'osmosis', + height: 12300000, + size: 127312345600, // ~118 GB in bytes + fileName: 'osmosis-1-12300000.tar.lz4', + createdAt: new Date('2024-01-09'), + updatedAt: new Date('2024-01-09'), + type: 'pruned', + compressionType: 'lz4', + }, + ], + 'juno': [ + { + id: 'juno-snapshot-1', + chainId: 'juno', + height: 12345678, + size: 250 * 1024 * 1024 * 1024, // 250 GB in bytes + fileName: 'juno-1-9876543.tar.lz4', + createdAt: new Date('2024-01-13'), + updatedAt: new Date('2024-01-13'), + type: 'pruned', + compressionType: 'lz4', + }, + ], +}; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ chainId: string }> } +) { + try { + const { chainId } = await params; + + // TODO: Implement actual database query + // const snapshots = await db.snapshot.findMany({ + // where: { chainId }, + // orderBy: { height: 'desc' } + // }); + + const snapshots = mockSnapshots[chainId] || []; + + return NextResponse.json>({ + success: true, + data: snapshots, + }); + } catch (error) { + return NextResponse.json( + { + success: false, + error: 'Failed to fetch snapshots', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/v1/chains/route.ts b/app/api/v1/chains/route.ts new file mode 100644 index 0000000..7931073 --- /dev/null +++ b/app/api/v1/chains/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ApiResponse, Chain } from '@/lib/types'; +import { collectResponseTime, trackRequest } from '@/lib/monitoring/metrics'; +import { extractRequestMetadata, logRequest } from '@/lib/middleware/logger'; + +// Mock data - replace with actual database queries +const mockChains: Chain[] = [ + { + id: 'cosmos-hub', + name: 'Cosmos Hub', + network: 'cosmoshub-4', + description: 'The Cosmos Hub is the first of thousands of interconnected blockchains.', + logoUrl: '/chains/cosmos.png', + }, + { + id: 'osmosis', + name: 'Osmosis', + network: 'osmosis-1', + description: 'Osmosis is an advanced AMM protocol for interchain assets.', + logoUrl: '/chains/osmosis.png', + }, + { + id: 'juno', + name: 'Juno', + network: 'juno-1', + description: 'Juno is a sovereign public blockchain in the Cosmos ecosystem.', + logoUrl: '/chains/juno.png', + }, +]; + +export async function GET(request: NextRequest) { + const endTimer = collectResponseTime('GET', '/api/v1/chains'); + const startTime = Date.now(); + const requestLog = extractRequestMetadata(request); + + try { + // TODO: Implement actual database query + // const chains = await db.chain.findMany(); + + const response = NextResponse.json>({ + success: true, + data: mockChains, + }); + + endTimer(); + trackRequest('GET', '/api/v1/chains', 200); + logRequest({ + ...requestLog, + responseStatus: 200, + responseTime: Date.now() - startTime, + }); + + return response; + } catch (error) { + const response = NextResponse.json( + { + success: false, + error: 'Failed to fetch chains', + message: error instanceof Error ? error.message : 'Unknown error', + }, + { status: 500 } + ); + + endTimer(); + trackRequest('GET', '/api/v1/chains', 500); + logRequest({ + ...requestLog, + responseStatus: 500, + responseTime: Date.now() - startTime, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + return response; + } +} \ No newline at end of file diff --git a/app/chains/[chainId]/page.tsx b/app/chains/[chainId]/page.tsx deleted file mode 100644 index f1c602e..0000000 --- a/app/chains/[chainId]/page.tsx +++ /dev/null @@ -1,224 +0,0 @@ -"use client"; - -import { use } from "react"; -import { motion } from "framer-motion"; -import { - Breadcrumb, - NetworkSelector, - SnapshotTable, - CopyableValue, -} from "@/components"; -import { SkeletonSnapshotTable } from "@/components/ui/SkeletonLoader"; -import { getChainById } from "@/lib/data/chains"; -import { useEnhancedChainData } from "@/lib/hooks/useEnhancedChainData"; -import { notFound } from "next/navigation"; -import { usePolkachuSnapshots, useNetworkTabs } from "@/lib/hooks"; - -// Animation variants -const pageVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - staggerChildren: 0.1, - }, - }, -}; - -const sectionVariants = { - hidden: { opacity: 0, y: 30 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - }, - }, -}; - -interface ChainDetailProps { - params: Promise<{ - chainId: string; - }>; -} - -export default function ChainDetail({ params }: ChainDetailProps) { - // Unwrap params Promise for Next.js 15 compatibility - const resolvedParams = use(params); - const chainId = - resolvedParams.chainId === "cosmoshub" ? "cosmos" : resolvedParams.chainId; - - // Get static chain data first (required for routing) - const staticConfig = getChainById(resolvedParams.chainId); - - // If static config not found, show 404 - if (!staticConfig) { - notFound(); - } - - // Use the enhanced chain data hook - const { enrichedChain, liveData, isLoadingLive, liveDataError } = - useEnhancedChainData(staticConfig, chainId); - - // Use the network tabs hook for tab management - const { - selectedNetwork, - setSelectedNetwork, - availableNetworks, - currentTabValue, - } = useNetworkTabs(enrichedChain.id); - - const { data: snapshots, isLoading: isLoadingSnapshots } = - usePolkachuSnapshots({ - network: chainId, - type: currentTabValue.apiType, - }); - - return ( - -
- {/* Breadcrumb Navigation */} - - - - - {/* Header Section */} - -
- - {enrichedChain.name} - - - - {isLoadingLive ? ( -
-
- Loading live data... -
- ) : liveDataError ? ( -
-
- API error - using static data -
- ) : liveData ? ( -
-
- Live data -
- ) : ( -
-
- Static data -
- )} -
-
- - - {enrichedChain.description} - - - {/* Network Selector */} - - - -
- - {/* Chain Information Card */} - {snapshots && ( - -
-

- Chain Information -

-
-
- - Node Version: - - - - {enrichedChain.binary?.version || "N/A"} - - -
-
- - Snapshot Name: - - - - {snapshots.snapshot.name} - - -
-
-
-
- )} - - {/* Snapshot Options Table */} - {isLoadingSnapshots ? ( - - - - ) : snapshots ? ( - - - - ) : null} -
-
- ); -} diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..b37c8cf --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error('Global error:', error); + }, [error]); + + return ( + + +
+
+ {/* Error icon */} +
+ + + +
+ + {/* Error message */} +
+

+ Something went wrong! +

+

+ An unexpected error occurred. Our team has been notified. +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+ + {/* Actions */} +
+ + + Go home + +
+ + {/* Support info */} +
+

+ If this problem persists, please contact{' '} + + support@bryanlabs.net + +

+
+
+
+ + + ); +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 75b5652..a448f90 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { AuthProvider } from "@/components/providers/AuthProvider"; +import { Header } from "@/components/common/Header"; +import { LayoutProvider } from "@/components/providers/LayoutProvider"; const inter = Inter({ variable: "--font-inter", @@ -70,10 +73,14 @@ export default function RootLayout({ return ( - - - {children} + + +
+ + {children} + + ); diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..19274bf --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,45 @@ +export default function RootLoading() { + return ( +
+
+ {/* Header skeleton */} +
+
+
+
+ + {/* Hero section skeleton */} +
+
+
+
+ + {/* Stats skeleton */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ + {/* Chain list skeleton */} +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/osmosis-static/page.tsx b/app/osmosis-static/page.tsx new file mode 100644 index 0000000..1d30ea1 --- /dev/null +++ b/app/osmosis-static/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { SnapshotItem } from '@/components/snapshots/SnapshotItem'; + +const osmosisSnapshots = [ + { + id: 'osmosis-snapshot-1', + chainId: 'osmosis', + height: 12345678, + size: 128849018880, + fileName: 'osmosis-1-12345678.tar.lz4', + createdAt: new Date('2024-01-10'), + updatedAt: new Date('2024-01-10'), + type: 'pruned' as const, + compressionType: 'lz4' as const, + }, + { + id: 'osmosis-snapshot-2', + chainId: 'osmosis', + height: 12300000, + size: 127312345600, + fileName: 'osmosis-1-12300000.tar.lz4', + createdAt: new Date('2024-01-09'), + updatedAt: new Date('2024-01-09'), + type: 'pruned' as const, + compressionType: 'lz4' as const, + }, +]; + +export default function OsmosisStaticPage() { + return ( +
+

Osmosis Snapshots

+

Static test page for Osmosis snapshots

+ +
+ {osmosisSnapshots.map(snapshot => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 328526e..f8b9ead 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,218 +1,103 @@ -"use client"; - -import Image from "next/image"; -import { useState, useEffect } from "react"; -import { motion } from "framer-motion"; -import { - SearchIcon, - HeroStats, - SnapshotsGrid, - StatusIndicator, -} from "@/components"; -import { - getMigrationStatus, - MigrationStatus, -} from "@/lib/utils/data-migration"; - -// Animation variants -const heroVariants = { - hidden: { opacity: 0, y: 30 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.8, - }, - }, -}; - -const searchVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - delay: 0.3, - }, - }, -}; - -const statsVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - duration: 0.6, - delay: 0.5, - staggerChildren: 0.1, - }, - }, -}; - -const gridVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - duration: 0.6, - delay: 0.7, - }, - }, -}; - -export default function Home() { - const [searchQuery, setSearchQuery] = useState(""); - const [migrationStatus, setMigrationStatus] = - useState(null); - const [isInitialLoading, setIsInitialLoading] = useState(true); - - useEffect(() => { - const fetchStatus = async () => { - try { - // Minimum loading time to show the orange indicator - const [status] = await Promise.all([ - getMigrationStatus(), - new Promise((resolve) => setTimeout(resolve, 800)), // Minimum 800ms loading - ]); - setMigrationStatus(status); - } catch (error) { - console.error("Error fetching migration status:", error); - } finally { - setIsInitialLoading(false); - } - }; - - fetchStatus(); - }, []); - +import { ChainListServer } from '@/components/chains/ChainListServer'; +import Image from 'next/image'; +import { Suspense } from 'react'; +import { UpgradePrompt } from '@/components/common/UpgradePrompt'; +import { getUser } from '@/lib/auth/session'; + +export default async function Home() { + const user = await getUser(); return ( -
- -
- - BryanLabs Logo - - - {/* Hero Title */} - - Blockchain Snapshots - - - {/* Hero Subtitle */} - - Fast, reliable blockchain snapshots for Cosmos ecosystem chains - - - {/* Feature Highlights */} - - - Updated daily - - - - Pruned options available - - - - Global CDN delivery - - - - {/* Hero Statistics */} - - - - - {/* Data Source Status */} - - - +
+ {/* Hero Section */} +
+
+
+
+ BryanLabs Logo +
+ +

+ Blockchain Snapshots +

+ +

+ Fast, reliable blockchain snapshots for Cosmos ecosystem chains +

+ +
+ + + + + Updated daily + + + + + + + Pruned options available + + + + + + + Global CDN delivery + +
+
- - - {/* Snapshots Section */} - -
- -

- Available Snapshots +

+ + {/* Chains Section */} +
+
+
+

+ Available Chains

-

+

Choose from our collection of daily-updated blockchain snapshots, available in both full and pruned versions

- - {/* Search Bar */} - -
- - setSearchQuery(e.target.value)} - className="w-full pl-12 pr-6 py-4 text-lg border-2 border-border rounded-xl bg-white transition-all duration-200 focus:outline-none focus:border-accent focus:ring-4 focus:ring-accent/10 placeholder:text-muted font-medium" - aria-label="Search blockchain chains" - /> +
+ + {/* Upgrade prompt for non-premium users */} + {!user && ( +
+ +
+ )} + + + {[1, 2, 3, 4, 5, 6].map((i) => ( +
+
+
+
+
+
+
+
+
+
+ ))}
- - - - + } + > + +
- +
); -} +} \ No newline at end of file diff --git a/app/test/page.tsx b/app/test/page.tsx new file mode 100644 index 0000000..9db0789 --- /dev/null +++ b/app/test/page.tsx @@ -0,0 +1,8 @@ +export default function TestPage() { + return ( +
+

Test Page

+

This is a simple test page to verify routing works.

+
+ ); +} \ No newline at end of file diff --git a/components/admin/ActiveConnections.tsx b/components/admin/ActiveConnections.tsx new file mode 100644 index 0000000..53bc9e0 --- /dev/null +++ b/components/admin/ActiveConnections.tsx @@ -0,0 +1,70 @@ +'use client'; + +export function ActiveConnections() { + // Mock data for demonstration + const connections = [ + { id: '1', chain: 'cosmos', user: 'anonymous', tier: 'free', speed: '12.5 MB/s', duration: '2m 15s' }, + { id: '2', chain: 'osmosis', user: 'premium_user', tier: 'premium', speed: '125 MB/s', duration: '45s' }, + { id: '3', chain: 'juno', user: 'anonymous', tier: 'free', speed: '10.2 MB/s', duration: '5m 30s' }, + ]; + + return ( +
+

+ Active Connections +

+
+ + + + + + + + + + + + {connections.map((conn) => ( + + + + + + + + ))} + +
+ Chain + + User + + Tier + + Speed + + Duration +
+ {conn.chain} + + {conn.user} + + + {conn.tier} + + + {conn.speed} + + {conn.duration} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/admin/AdminStats.tsx b/components/admin/AdminStats.tsx new file mode 100644 index 0000000..adb45ad --- /dev/null +++ b/components/admin/AdminStats.tsx @@ -0,0 +1,64 @@ +interface AdminStatsProps { + stats: { + activeConnections: number; + connectionsByTier: { + free: number; + premium: number; + }; + totalBandwidthUsage: string; + userCount: number; + }; +} + +export function AdminStats({ stats }: AdminStatsProps) { + const statCards = [ + { + title: 'Active Downloads', + value: stats.activeConnections, + icon: '📥', + description: 'Current active connections', + }, + { + title: 'Free Tier Users', + value: stats.connectionsByTier.free, + icon: '👥', + description: 'Active free tier downloads', + }, + { + title: 'Premium Users', + value: stats.connectionsByTier.premium, + icon: '⭐', + description: 'Active premium downloads', + }, + { + title: 'Total Bandwidth', + value: stats.totalBandwidthUsage, + icon: '📊', + description: 'Total bandwidth consumed', + }, + ]; + + return ( +
+ {statCards.map((stat) => ( +
+
+ {stat.icon} + + {stat.value} + +
+

+ {stat.title} +

+

+ {stat.description} +

+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/components/admin/BandwidthChart.tsx b/components/admin/BandwidthChart.tsx new file mode 100644 index 0000000..003711c --- /dev/null +++ b/components/admin/BandwidthChart.tsx @@ -0,0 +1,17 @@ +'use client'; + +export function BandwidthChart() { + return ( +
+

+ Bandwidth Usage (24h) +

+
+

Chart visualization would go here

+
+

+ To implement: Integrate with a charting library like Chart.js or Recharts +

+
+ ); +} \ No newline at end of file diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx new file mode 100644 index 0000000..7130714 --- /dev/null +++ b/components/auth/LoginForm.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '../providers/AuthProvider'; +import { LoadingSpinner } from '../common/LoadingSpinner'; + +export function LoginForm() { + const router = useRouter(); + const { login, error } = useAuth(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + const success = await login({ email, password }); + + if (success) { + router.push('/'); + } + setIsLoading(false); + }; + + return ( +
+
+

+ Login to BryanLabs Snapshots +

+ +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" + placeholder="••••••••" + /> +
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/auth/index.ts b/components/auth/index.ts new file mode 100644 index 0000000..36629f0 --- /dev/null +++ b/components/auth/index.ts @@ -0,0 +1 @@ +export * from './LoginForm'; \ No newline at end of file diff --git a/components/chains/ChainCard.tsx b/components/chains/ChainCard.tsx new file mode 100644 index 0000000..fab2a17 --- /dev/null +++ b/components/chains/ChainCard.tsx @@ -0,0 +1,76 @@ +import Link from 'next/link'; +import Image from 'next/image'; +import { Chain } from '@/lib/types'; + +interface ChainCardProps { + chain: Chain; +} + +export function ChainCard({ chain }: ChainCardProps) { + const snapshotCount = chain.snapshots?.length || 0; + const latestSnapshot = chain.snapshots?.[0]; + + return ( + +
+
+
+ {chain.logoUrl && ( +
+ {`${chain.name} +
+ )} +
+

+ {chain.name} +

+

+ {chain.network} +

+
+
+ + Active + +
+ + {chain.description && ( +

+ {chain.description} +

+ )} + +
+
+ + {snapshotCount} snapshot{snapshotCount !== 1 ? 's' : ''} + + {latestSnapshot && ( + + Latest: Block #{latestSnapshot.height.toLocaleString()} + + )} +
+ + + +
+
+ + ); +} \ No newline at end of file diff --git a/components/chains/ChainList.tsx b/components/chains/ChainList.tsx new file mode 100644 index 0000000..359ce0a --- /dev/null +++ b/components/chains/ChainList.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Chain } from '@/lib/types'; +import { ChainCard } from './ChainCard'; +import { LoadingSpinner } from '../common/LoadingSpinner'; +import { ErrorMessage } from '../common/ErrorMessage'; +import { useChains } from '@/hooks/useChains'; + +export function ChainList() { + const { chains, loading, error, refetch } = useChains(); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedNetwork, setSelectedNetwork] = useState('all'); + + const networks = useMemo(() => { + if (!chains) return []; + const uniqueNetworks = [...new Set(chains.map(chain => chain.network))]; + return uniqueNetworks.sort(); + }, [chains]); + + const filteredChains = useMemo(() => { + if (!chains) return []; + + return chains.filter(chain => { + const matchesSearch = searchTerm === '' || + chain.name.toLowerCase().includes(searchTerm.toLowerCase()) || + chain.id.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesNetwork = selectedNetwork === 'all' || chain.network === selectedNetwork; + + return matchesSearch && matchesNetwork; + }); + }, [chains, searchTerm, selectedNetwork]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Filters */} +
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" + /> +
+ + +
+ + {/* Results count */} +
+ Showing {filteredChains.length} of {chains?.length || 0} chains +
+ + {/* Chain Grid */} + {filteredChains.length === 0 ? ( +
+

+ No chains found matching your criteria +

+
+ ) : ( +
+ {filteredChains.map(chain => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/chains/ChainListClient.tsx b/components/chains/ChainListClient.tsx new file mode 100644 index 0000000..5e26a23 --- /dev/null +++ b/components/chains/ChainListClient.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Chain } from '@/lib/types'; +import { ChainCard } from './ChainCard'; + +interface ChainListClientProps { + initialChains: Chain[]; +} + +export function ChainListClient({ initialChains }: ChainListClientProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedNetwork, setSelectedNetwork] = useState('all'); + + const networks = useMemo(() => { + const uniqueNetworks = [...new Set(initialChains.map(chain => chain.network))]; + return uniqueNetworks.sort(); + }, [initialChains]); + + const filteredChains = useMemo(() => { + return initialChains.filter(chain => { + const matchesSearch = searchTerm === '' || + chain.name.toLowerCase().includes(searchTerm.toLowerCase()) || + chain.id.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesNetwork = selectedNetwork === 'all' || chain.network === selectedNetwork; + + return matchesSearch && matchesNetwork; + }); + }, [initialChains, searchTerm, selectedNetwork]); + + return ( +
+ {/* Filters */} +
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-700 dark:text-white" + /> +
+ + +
+ + {/* Results count */} +
+ Showing {filteredChains.length} of {initialChains.length} chains +
+ + {/* Chain Grid */} + {filteredChains.length === 0 ? ( +
+

+ No chains found matching your criteria +

+
+ ) : ( +
+ {filteredChains.map(chain => ( + + ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/chains/ChainListServer.tsx b/components/chains/ChainListServer.tsx new file mode 100644 index 0000000..db8a280 --- /dev/null +++ b/components/chains/ChainListServer.tsx @@ -0,0 +1,26 @@ +import { Chain } from '@/lib/types'; +import { ChainListClient } from './ChainListClient'; + +async function getChains(): Promise { + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'}/api/v1/chains`, { + next: { revalidate: 60 } // Cache for 1 minute + }); + + if (!response.ok) { + throw new Error('Failed to fetch chains'); + } + + const data = await response.json(); + return data.success ? data.data : []; + } catch (error) { + console.error('Failed to fetch chains:', error); + return []; + } +} + +export async function ChainListServer() { + const chains = await getChains(); + + return ; +} \ No newline at end of file diff --git a/components/chains/index.ts b/components/chains/index.ts new file mode 100644 index 0000000..c2838dc --- /dev/null +++ b/components/chains/index.ts @@ -0,0 +1,2 @@ +export * from './ChainList'; +export * from './ChainCard'; \ No newline at end of file diff --git a/components/common/DownloadModal.tsx b/components/common/DownloadModal.tsx new file mode 100644 index 0000000..2a6346e --- /dev/null +++ b/components/common/DownloadModal.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState } from 'react'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/hooks/useAuth'; +import Link from 'next/link'; + +interface DownloadModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + snapshot: { + chainId: string; + filename: string; + size: string; + blockHeight?: number; + }; + isLoading?: boolean; +} + +export function DownloadModal({ + isOpen, + onClose, + onConfirm, + snapshot, + isLoading = false +}: DownloadModalProps) { + const { user } = useAuth(); + const tier = user ? 'premium' : 'free'; + + const bandwidthInfo = { + free: { + speed: '50 MB/s', + description: 'Shared among all free users', + estimatedTime: calculateDownloadTime(snapshot.size, 50), + benefits: [ + 'Resume support for interrupted downloads', + 'Secure pre-signed URLs', + 'Limited to 50 MB/s shared bandwidth' + ] + }, + premium: { + speed: '250 MB/s', + description: 'Shared among premium users', + estimatedTime: calculateDownloadTime(snapshot.size, 250), + benefits: [ + '5x faster downloads', + 'Priority bandwidth allocation', + 'Resume support for interrupted downloads', + 'Secure pre-signed URLs', + 'Premium support' + ] + } + }; + + const tierInfo = bandwidthInfo[tier]; + + return ( + + + + Download Snapshot + + {snapshot.chainId} - {snapshot.filename} + + + +
+ {/* File info */} +
+
+ File size: + {snapshot.size} +
+ {snapshot.blockHeight && ( +
+ Block height: + {snapshot.blockHeight.toLocaleString()} +
+ )} +
+ Estimated time: + {tierInfo.estimatedTime} +
+
+ + {/* Bandwidth tier info */} +
+
+

+ {tier === 'premium' ? 'Premium' : 'Free'} Tier +

+ + {tierInfo.speed} + +
+

+ {tierInfo.description} +

+
    + {tierInfo.benefits.map((benefit, i) => ( +
  • + + + + {benefit} +
  • + ))} +
+
+ + {/* Upgrade prompt for free users */} + {tier === 'free' && ( +
+

+ Want faster downloads? +

+

+ Upgrade to Premium for 5x faster speeds and priority access. +

+ + Login for Premium access + + + + +
+ )} +
+ + + + + +
+
+ ); +} + +function calculateDownloadTime(sizeStr: string, speedMbps: number): string { + if (!sizeStr) return 'Unknown'; + + // Parse size string (e.g., "7.3 GB", "500 MB") + const match = sizeStr.match(/^([\d.]+)\s*(GB|MB|TB|KB|Bytes?)$/i); + if (!match) return 'Unknown'; + + const [, sizeNum, unit] = match; + let sizeInMB = parseFloat(sizeNum); + + // Convert to MB + const upperUnit = unit.toUpperCase(); + if (upperUnit === 'GB') { + sizeInMB *= 1024; + } else if (upperUnit === 'TB') { + sizeInMB *= 1024 * 1024; + } else if (upperUnit === 'KB') { + sizeInMB /= 1024; + } else if (upperUnit === 'BYTES' || upperUnit === 'B') { + sizeInMB /= (1024 * 1024); + } + + // Calculate time in seconds + const timeInSeconds = sizeInMB / speedMbps; + + // Format time + if (timeInSeconds < 60) { + return `${Math.ceil(timeInSeconds)} seconds`; + } else if (timeInSeconds < 3600) { + const minutes = Math.ceil(timeInSeconds / 60); + return `${minutes} minute${minutes > 1 ? 's' : ''}`; + } else { + const hours = Math.floor(timeInSeconds / 3600); + const minutes = Math.ceil((timeInSeconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } +} \ No newline at end of file diff --git a/components/common/ErrorMessage.tsx b/components/common/ErrorMessage.tsx new file mode 100644 index 0000000..098c8dc --- /dev/null +++ b/components/common/ErrorMessage.tsx @@ -0,0 +1,43 @@ +interface ErrorMessageProps { + title?: string; + message: string; + onRetry?: () => void; +} + +export function ErrorMessage({ title = 'Error', message, onRetry }: ErrorMessageProps) { + return ( +
+
+
+ + + +
+
+

+ {title} +

+
+ {message} +
+ {onRetry && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/components/common/Header.tsx b/components/common/Header.tsx new file mode 100644 index 0000000..da09785 --- /dev/null +++ b/components/common/Header.tsx @@ -0,0 +1,119 @@ +'use client'; + +import Link from 'next/link'; +import { useAuth } from '../providers/AuthProvider'; +import { useState } from 'react'; +import { UpgradePrompt } from './UpgradePrompt'; +import { ThemeToggle } from './ThemeToggle'; + +export function Header() { + const { user, logout } = useAuth(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( + <> + {/* Upgrade banner for free users */} + {!user && } + +
+
+
+ {/* Logo */} + + BryanLabs + Snapshots + + + {/* Desktop Navigation */} + + + {/* Mobile Menu Button */} + +
+ + {/* Mobile Menu */} + {isMenuOpen && ( +
+ +
+ )} +
+
+ + ); +} \ No newline at end of file diff --git a/components/common/LoadingSpinner.tsx b/components/common/LoadingSpinner.tsx new file mode 100644 index 0000000..c4cf0d6 --- /dev/null +++ b/components/common/LoadingSpinner.tsx @@ -0,0 +1,34 @@ +export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-8 h-8', + lg: 'w-12 h-12' + }; + + return ( +
+
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/components/common/ThemeToggle.tsx b/components/common/ThemeToggle.tsx new file mode 100644 index 0000000..0a951d4 --- /dev/null +++ b/components/common/ThemeToggle.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export function ThemeToggle() { + const [theme, setTheme] = useState<'light' | 'dark'>('light'); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + // Check localStorage and system preference + const savedTheme = localStorage.getItem('theme'); + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + const initialTheme = (savedTheme as 'light' | 'dark') || systemTheme; + + setTheme(initialTheme); + document.documentElement.classList.toggle('dark', initialTheme === 'dark'); + }, []); + + const toggleTheme = () => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + document.documentElement.classList.toggle('dark', newTheme === 'dark'); + }; + + // Avoid hydration mismatch + if (!mounted) { + return ( + + ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/components/common/UpgradePrompt.tsx b/components/common/UpgradePrompt.tsx new file mode 100644 index 0000000..9b047a9 --- /dev/null +++ b/components/common/UpgradePrompt.tsx @@ -0,0 +1,94 @@ +import Link from 'next/link'; + +interface UpgradePromptProps { + variant?: 'inline' | 'banner' | 'card'; + className?: string; +} + +export function UpgradePrompt({ variant = 'card', className = '' }: UpgradePromptProps) { + if (variant === 'inline') { + return ( +
+ + + + + + Upgrade to Premium + + {' '}for 5x faster downloads + +
+ ); + } + + if (variant === 'banner') { + return ( +
+
+
+ + + + + Premium users get 250 MB/s download speeds! + +
+ + Upgrade Now + +
+
+ ); + } + + // Default card variant + return ( +
+
+
+ + + +
+
+

+ Unlock Premium Benefits +

+
    +
  • + + + + 250 MB/s download speeds (5x faster) +
  • +
  • + + + + Priority bandwidth allocation +
  • +
  • + + + + Premium support access +
  • +
+ + Get Premium Access + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/components/common/index.ts b/components/common/index.ts new file mode 100644 index 0000000..28d8516 --- /dev/null +++ b/components/common/index.ts @@ -0,0 +1,3 @@ +export * from './Header'; +export * from './LoadingSpinner'; +export * from './ErrorMessage'; \ No newline at end of file diff --git a/components/icons/ChevronRightIcon.tsx b/components/icons/ChevronRightIcon.tsx deleted file mode 100644 index 8e10d85..0000000 --- a/components/icons/ChevronRightIcon.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export const ChevronRightIcon = () => ( - - - -); diff --git a/components/icons/DownloadIcon.tsx b/components/icons/DownloadIcon.tsx deleted file mode 100644 index 34c292b..0000000 --- a/components/icons/DownloadIcon.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const DownloadIcon = () => ( - - - - - -); diff --git a/components/icons/SearchIcon.tsx b/components/icons/SearchIcon.tsx deleted file mode 100644 index 6ce7867..0000000 --- a/components/icons/SearchIcon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const SearchIcon = () => ( - - - - -); diff --git a/components/icons/ViewIcon.tsx b/components/icons/ViewIcon.tsx deleted file mode 100644 index 6ac991b..0000000 --- a/components/icons/ViewIcon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -export const ViewIcon = () => ( - - - - -); diff --git a/components/icons/index.ts b/components/icons/index.ts index a1bf5a6..28a6f7c 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -1,5 +1 @@ -export { SearchIcon } from "./SearchIcon"; -export { DownloadIcon } from "./DownloadIcon"; -export { ViewIcon } from "./ViewIcon"; -export { ChevronRightIcon } from "./ChevronRightIcon"; export { CopyIcon } from "./CopyIcon"; diff --git a/components/index.ts b/components/index.ts index cc89ebf..ae2681c 100644 --- a/components/index.ts +++ b/components/index.ts @@ -4,5 +4,17 @@ export * from "./icons"; // UI Components export * from "./ui"; -// Sections -export * from "./sections"; +// Providers +export * from "./providers"; + +// Common Components +export * from "./common"; + +// Auth Components +export * from "./auth"; + +// Chain Components +export * from "./chains"; + +// Snapshot Components +export * from "./snapshots"; diff --git a/components/providers/AuthProvider.tsx b/components/providers/AuthProvider.tsx new file mode 100644 index 0000000..ee3cfba --- /dev/null +++ b/components/providers/AuthProvider.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { User, LoginRequest, ApiResponse } from '@/lib/types'; + +interface AuthContextType { + user: User | null; + loading: boolean; + error: string | null; + login: (credentials: LoginRequest) => Promise; + logout: () => Promise; + checkAuth: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const checkAuth = async () => { + try { + setLoading(true); + const response = await fetch('/api/v1/auth/me'); + if (response.ok) { + const data: ApiResponse = await response.json(); + if (data.success && data.data) { + setUser(data.data); + } + } + } catch (err) { + console.error('Auth check failed:', err); + } finally { + setLoading(false); + } + }; + + const login = async (credentials: LoginRequest): Promise => { + try { + setError(null); + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(credentials), + }); + + const data: ApiResponse = await response.json(); + + if (response.ok && data.success && data.data) { + setUser(data.data); + return true; + } else { + setError(data.error || 'Login failed'); + return false; + } + } catch (err) { + setError('An error occurred during login'); + return false; + } + }; + + const logout = async () => { + try { + await fetch('/api/v1/auth/logout', { method: 'POST' }); + setUser(null); + } catch (err) { + console.error('Logout failed:', err); + } + }; + + useEffect(() => { + checkAuth(); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} \ No newline at end of file diff --git a/components/providers/LayoutProvider.tsx b/components/providers/LayoutProvider.tsx new file mode 100644 index 0000000..fd4d1cd --- /dev/null +++ b/components/providers/LayoutProvider.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { useAuth } from './AuthProvider'; +import { ReactNode } from 'react'; + +export function LayoutProvider({ children }: { children: ReactNode }) { + const { user } = useAuth(); + + // Adjust padding based on whether the upgrade banner is shown + const paddingTop = user ? 'pt-16' : 'pt-28'; + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/components/providers/index.ts b/components/providers/index.ts new file mode 100644 index 0000000..0e7b0c5 --- /dev/null +++ b/components/providers/index.ts @@ -0,0 +1 @@ +export * from './AuthProvider'; \ No newline at end of file diff --git a/components/sections/SnapshotsGrid.tsx b/components/sections/SnapshotsGrid.tsx deleted file mode 100644 index f43df17..0000000 --- a/components/sections/SnapshotsGrid.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { useState, useEffect } from "react"; -import { SnapshotCard, ChainSnapshot } from "@/components/ui/SnapshotCard"; -import { getChainDataWithFallback } from "@/lib/utils/data-migration"; -import { SkeletonSnapshotCard } from "@/components/ui/SkeletonLoader"; - -interface SnapshotsGridProps { - searchQuery: string; -} - -const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - delayChildren: 0.1, - }, - }, -}; - -const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - }, - }, -}; - -export const SnapshotsGrid = ({ searchQuery }: SnapshotsGridProps) => { - const [chains, setChains] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [isLive, setIsLive] = useState(false); - - useEffect(() => { - const fetchChains = async () => { - try { - setIsLoading(true); - setError(null); - const { chains: enhancedChains, isLive: liveStatus } = - await getChainDataWithFallback(); - setChains(enhancedChains); - setIsLive(liveStatus); - } catch (err) { - console.error("Error fetching chains:", err); - setError("Failed to load chain data"); - setChains([]); // Empty array on error - setIsLive(false); - } finally { - setIsLoading(false); - } - }; - - fetchChains(); - }, []); - - const filteredChains = chains.filter( - (chain) => - chain.name.toLowerCase().includes(searchQuery.toLowerCase()) || - chain.network.toLowerCase().includes(searchQuery.toLowerCase()) || - chain.symbol.toLowerCase().includes(searchQuery.toLowerCase()) || - chain.description.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - if (error && chains.length === 0) { - return ( - - - Unable to load blockchain data - -

- Please check your connection and try again later. -

- window.location.reload()} - className="mt-4 px-6 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 transition-colors" - > - Retry - -
- ); - } - - if (isLoading) { - return ( - - {[...Array(6)].map((_, index) => ( - - - - ))} - - ); - } - - if (filteredChains.length === 0 && searchQuery) { - return ( - - - No chains found for "{searchQuery}" - -

- Try searching with different keywords or check the spelling. -

-
- ); - } - - return ( - - {filteredChains.map((chain, index) => ( - - - - ))} - - {/* Show count info */} - {!isLoading && ( - - {searchQuery ? ( - <> - Showing {filteredChains.length} of {chains.length} chains matching - "{searchQuery}" - - ) : ( - <> - Showing {filteredChains.length} blockchain networks with{" "} - {isLive ? "live" : "cached"} data - {isLive ? " from Polkachu" : " (API unavailable)"} - - )} - - )} - - ); -}; diff --git a/components/sections/index.ts b/components/sections/index.ts deleted file mode 100644 index c526844..0000000 --- a/components/sections/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SnapshotsGrid } from "./SnapshotsGrid"; diff --git a/components/snapshots/DownloadButton.tsx b/components/snapshots/DownloadButton.tsx new file mode 100644 index 0000000..ca84753 --- /dev/null +++ b/components/snapshots/DownloadButton.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useState } from 'react'; +import { Snapshot } from '@/lib/types'; +import { useAuth } from '../providers/AuthProvider'; +import { LoadingSpinner } from '../common/LoadingSpinner'; +import { DownloadModal } from '../common/DownloadModal'; + +interface DownloadButtonProps { + snapshot: Snapshot; + chainName: string; +} + +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +export function DownloadButton({ snapshot, chainName }: DownloadButtonProps) { + const { user } = useAuth(); + const [isDownloading, setIsDownloading] = useState(false); + const [progress, setProgress] = useState(0); + const [bandwidth, setBandwidth] = useState(null); + const [showModal, setShowModal] = useState(false); + + const handleDownloadClick = () => { + // Show modal for free users, proceed directly for premium users + if (!user) { + setShowModal(true); + } else { + handleDownload(); + } + }; + + const handleDownload = async () => { + setShowModal(false); + try { + setIsDownloading(true); + setProgress(0); + setBandwidth(null); + + // Get the download URL from the API + const response = await fetch(`/api/v1/chains/${snapshot.chainId}/download`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + snapshotId: snapshot.id, + email: user?.email + }), + }); + + if (!response.ok) { + throw new Error('Failed to get download URL'); + } + + const data = await response.json(); + + if (data.success && data.data?.downloadUrl) { + // Create a temporary link and click it + const link = document.createElement('a'); + link.href = data.data.downloadUrl; + link.download = snapshot.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Simulate download progress (in a real app, you'd track actual progress) + const interval = setInterval(() => { + setProgress(prev => { + if (prev >= 95) { + clearInterval(interval); + setTimeout(() => { + setIsDownloading(false); + setProgress(0); + setBandwidth(null); + }, 1000); + return 100; + } + + // Simulate bandwidth calculation + const mbps = (Math.random() * 50 + 50).toFixed(1); + setBandwidth(`${mbps} MB/s`); + + return prev + Math.random() * 10; + }); + }, 500); + } + } catch (error) { + console.error('Download failed:', error); + setIsDownloading(false); + setProgress(0); + setBandwidth(null); + } + }; + + return ( + <> +
+ + + {isDownloading && ( +
+
+
+
+ {bandwidth && ( +

+ {bandwidth} +

+ )} +
+ )} +
+ + setShowModal(false)} + onConfirm={handleDownload} + snapshot={{ + chainId: snapshot.chainId, + filename: snapshot.fileName, + size: formatFileSize(snapshot.size), + blockHeight: snapshot.height, + }} + isLoading={isDownloading} + /> + + ); +} \ No newline at end of file diff --git a/components/snapshots/SnapshotItem.tsx b/components/snapshots/SnapshotItem.tsx new file mode 100644 index 0000000..520550f --- /dev/null +++ b/components/snapshots/SnapshotItem.tsx @@ -0,0 +1,80 @@ +import { Snapshot } from '@/lib/types'; +import { DownloadButton } from './DownloadButton'; + +interface SnapshotCardProps { + snapshot: Snapshot; + chainName: string; +} + +export function SnapshotItem({ snapshot, chainName }: SnapshotCardProps) { + const formatSize = (bytes: number): string => { + const gb = bytes / (1024 * 1024 * 1024); + return `${gb.toFixed(2)} GB`; + }; + + const formatDate = (date: Date | string): string => { + const d = new Date(date); + return d.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const getTypeColor = (type: string) => { + switch (type) { + case 'archive': + return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'; + case 'pruned': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'; + } + }; + + const getCompressionBadge = (compression: string) => { + switch (compression) { + case 'lz4': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'zst': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200'; + } + }; + + return ( +
+
+
+
+

+ Block #{snapshot.height.toLocaleString()} +

+ + {snapshot.type} + + + {snapshot.compressionType.toUpperCase()} + +
+ +
+

Size: {formatSize(snapshot.size)}

+

Created: {formatDate(snapshot.createdAt)}

+

{snapshot.fileName}

+
+
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/components/snapshots/SnapshotList.tsx b/components/snapshots/SnapshotList.tsx new file mode 100644 index 0000000..31df9c2 --- /dev/null +++ b/components/snapshots/SnapshotList.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Snapshot } from '@/lib/types'; +import { SnapshotItem } from './SnapshotItem'; +import { LoadingSpinner } from '../common/LoadingSpinner'; +import { ErrorMessage } from '../common/ErrorMessage'; +import { useSnapshots } from '@/hooks/useSnapshots'; + +interface SnapshotListProps { + chainId: string; + chainName: string; +} + +export function SnapshotList({ chainId, chainName }: SnapshotListProps) { + const { snapshots, loading, error, refetch } = useSnapshots(chainId); + const [selectedType, setSelectedType] = useState('all'); + + const filteredSnapshots = useMemo(() => { + if (!snapshots) return []; + + if (selectedType === 'all') return snapshots; + + return snapshots.filter(snapshot => snapshot.type === selectedType); + }, [snapshots, selectedType]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + ); + } + + if (!snapshots || snapshots.length === 0) { + return ( +
+

+ No snapshots available for this chain yet. +

+
+ ); + } + + return ( +
+ {/* Filter Tabs */} +
+ +
+ + {/* Snapshots */} +
+ {filteredSnapshots.map(snapshot => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/components/snapshots/SnapshotListClient.tsx b/components/snapshots/SnapshotListClient.tsx new file mode 100644 index 0000000..638c1bd --- /dev/null +++ b/components/snapshots/SnapshotListClient.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { Snapshot } from '@/lib/types'; +import { SnapshotItem } from './SnapshotItem'; + +interface SnapshotListClientProps { + chainId: string; + chainName: string; + initialSnapshots: Snapshot[]; +} + +export function SnapshotListClient({ chainId, chainName, initialSnapshots }: SnapshotListClientProps) { + const [selectedType, setSelectedType] = useState('all'); + + const filteredSnapshots = useMemo(() => { + if (selectedType === 'all') return initialSnapshots; + return initialSnapshots.filter(snapshot => snapshot.type === selectedType); + }, [initialSnapshots, selectedType]); + + if (initialSnapshots.length === 0) { + return ( +
+

+ No snapshots available for this chain yet. +

+
+ ); + } + + return ( +
+ {/* Filter Tabs */} +
+ +
+ + {/* Snapshots */} +
+ {filteredSnapshots.map(snapshot => ( + + ))} +
+
+ ); +} \ No newline at end of file diff --git a/components/snapshots/SnapshotListServer.tsx b/components/snapshots/SnapshotListServer.tsx new file mode 100644 index 0000000..fd4f6cd --- /dev/null +++ b/components/snapshots/SnapshotListServer.tsx @@ -0,0 +1,50 @@ +import { Snapshot } from '@/lib/types'; +import { SnapshotListClient } from './SnapshotListClient'; + +interface SnapshotListServerProps { + chainId: string; + chainName: string; +} + +async function getSnapshots(chainId: string): Promise { + try { + // Use the internal API URL for server-side requests + const apiUrl = process.env.INTERNAL_API_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + const response = await fetch( + `${apiUrl}/api/v1/chains/${chainId}/snapshots`, + { + next: { revalidate: 300 } // Cache for 5 minutes + } + ); + + if (!response.ok) { + throw new Error('Failed to fetch snapshots'); + } + + const data = await response.json(); + return data.success ? data.data : []; + } catch (error) { + console.error('Failed to fetch snapshots:', error); + return []; + } +} + +export async function SnapshotListServer({ chainId, chainName }: SnapshotListServerProps) { + const snapshots = await getSnapshots(chainId); + + if (!snapshots || snapshots.length === 0) { + return ( +
+

+ We couldn't find snapshots for {chainName}. The chain might not be available or there could be a temporary issue. +

+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/components/snapshots/index.ts b/components/snapshots/index.ts new file mode 100644 index 0000000..ba9e0fd --- /dev/null +++ b/components/snapshots/index.ts @@ -0,0 +1,3 @@ +export * from './SnapshotList'; +export * from './SnapshotItem'; +export * from './DownloadButton'; \ No newline at end of file diff --git a/components/ui/Breadcrumb.tsx b/components/ui/Breadcrumb.tsx deleted file mode 100644 index f4af271..0000000 --- a/components/ui/Breadcrumb.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Link from "next/link"; -import { ChevronRightIcon } from "../icons"; - -interface BreadcrumbItem { - label: string; - href?: string; -} - -interface BreadcrumbProps { - items: BreadcrumbItem[]; -} - -export const Breadcrumb = ({ items }: BreadcrumbProps) => ( - -); diff --git a/components/ui/ChainIcon.tsx b/components/ui/ChainIcon.tsx deleted file mode 100644 index f5f7c42..0000000 --- a/components/ui/ChainIcon.tsx +++ /dev/null @@ -1,11 +0,0 @@ -interface ChainIconProps { - name: string; -} - -export const ChainIcon = ({ name }: ChainIconProps) => ( -
- - {name.slice(0, 2).toUpperCase()} - -
-); diff --git a/components/ui/CodeBlock.tsx b/components/ui/CodeBlock.tsx deleted file mode 100644 index 03c944c..0000000 --- a/components/ui/CodeBlock.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { CopyIcon } from "../icons"; - -interface CodeBlockProps { - code: string; - language?: string; - title?: string; -} - -export const CodeBlock = ({ - code, - language = "bash", - title, -}: CodeBlockProps) => { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy text: ", err); - } - }; - - return ( -
- {title && ( -
- {title} -
- )} -
-
-          {code}
-        
- -
-
- ); -}; diff --git a/components/ui/HeroStats.tsx b/components/ui/HeroStats.tsx deleted file mode 100644 index 0fdfb38..0000000 --- a/components/ui/HeroStats.tsx +++ /dev/null @@ -1,164 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { useState, useEffect } from "react"; -import { getChainDataWithFallback } from "@/lib/utils/data-migration"; -import { DynamicStats } from "@/lib/api/polkachu"; - -const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.2, - delayChildren: 0.1, - }, - }, -}; - -const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.6, - }, - }, -}; - -export const HeroStats = () => { - const [stats, setStats] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchStats = async () => { - try { - setIsLoading(true); - setError(null); - const { stats: dynamicStats } = await getChainDataWithFallback(); - setStats(dynamicStats); - } catch (err) { - console.error("Error fetching stats:", err); - setError("Failed to load statistics"); - // Fallback to static data on error - setStats({ - totalChains: 18, - updateFrequency: "Daily", - }); - } finally { - setIsLoading(false); - } - }; - - fetchStats(); - }, []); - - const getDisplayStats = () => { - if (!stats) return []; - - const baseStats = [ - { - number: `${stats.totalChains}+`, - label: "Chains Available", - color: "text-foreground", - }, - { - number: stats.updateFrequency, - label: "Updates", - color: "text-foreground", - }, - ]; - - return baseStats; - }; - - if (error && !stats) { - return ( - - - Unable to load statistics - - - ); - } - - const displayStats = getDisplayStats(); - - return ( - - {isLoading - ? // Loading state with skeleton - [...Array(2)].map((_, index) => ( - - - - - )) - : displayStats.map((stat, index) => ( - - - {stat.number} - - - {stat.label} - - - ))} - - ); -}; diff --git a/components/ui/InfoCard.tsx b/components/ui/InfoCard.tsx deleted file mode 100644 index 50f974a..0000000 --- a/components/ui/InfoCard.tsx +++ /dev/null @@ -1,30 +0,0 @@ -interface InfoCardProps { - title: string; - children: React.ReactNode; - className?: string; -} - -export const InfoCard = ({ - title, - children, - className = "", -}: InfoCardProps) => ( -
-

{title}

-
{children}
-
-); - -interface InfoRowProps { - label: string; - value: string | React.ReactNode; -} - -export const InfoRow = ({ label, value }: InfoRowProps) => ( -
- {label}: - {value} -
-); diff --git a/components/ui/NetworkSelector.tsx b/components/ui/NetworkSelector.tsx deleted file mode 100644 index 1a3ed58..0000000 --- a/components/ui/NetworkSelector.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { useState } from "react"; - -interface NetworkSelectorProps { - options: string[]; - defaultSelected?: string; - onSelect?: (network: string) => void; -} - -export const NetworkSelector = ({ - options, - defaultSelected = options[0], - onSelect, -}: NetworkSelectorProps) => { - const [selected, setSelected] = useState(defaultSelected); - - const handleSelect = (network: string) => { - setSelected(network); - onSelect?.(network); - }; - - return ( -
- {options.map((option) => ( - - ))} -
- ); -}; diff --git a/components/ui/SkeletonLoader.tsx b/components/ui/SkeletonLoader.tsx deleted file mode 100644 index e4edfd6..0000000 --- a/components/ui/SkeletonLoader.tsx +++ /dev/null @@ -1,251 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; - -interface SkeletonLoaderProps { - className?: string; - variant?: "text" | "circle" | "rectangle"; - width?: string | number; - height?: string | number; - animationDelay?: number; -} - -export const SkeletonLoader = ({ - className = "", - variant = "rectangle", - width, - height, - animationDelay = 0, -}: SkeletonLoaderProps) => { - const baseClasses = "bg-slate-200 animate-pulse"; - - const variantClasses = { - text: "h-4 rounded", - circle: "rounded-full", - rectangle: "rounded-lg", - }; - - const style = { - width: typeof width === "number" ? `${width}px` : width, - height: typeof height === "number" ? `${height}px` : height, - }; - - return ( - - ); -}; - -// Predefined skeleton patterns -export const SkeletonCard = ({ index = 0 }: { index?: number }) => ( -
-
- -
- - -
-
- -
- {[...Array(4)].map((_, i) => ( -
- - -
- ))} -
- -
- - -
-
-); - -export const SkeletonStats = ({ count = 2 }: { count?: number }) => ( -
- {[...Array(count)].map((_, index) => ( -
- - -
- ))} -
-); - -export const SkeletonSnapshotTable = () => ( -
-
- - - - - - - - - - - - - - - - - - - -
- Time - - Block Height - - Size - - Last Updated - - Action -
- - - - - - - - - -
-
-
-); - -export const SkeletonSnapshotCard = ({ index = 0 }: { index?: number }) => ( -
- {/* Header with icon and title */} -
- -
- - -
-
- - {/* Data rows */} -
- {[...Array(3)].map((_, i) => ( -
- - -
- ))} -
- - {/* Buttons */} -
- - -
-
-); diff --git a/components/ui/SnapshotCard.tsx b/components/ui/SnapshotCard.tsx deleted file mode 100644 index b9a0378..0000000 --- a/components/ui/SnapshotCard.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { motion } from "framer-motion"; -import { DownloadIcon } from "../icons"; -import { ChainIcon } from "./ChainIcon"; -import { CopyableValue } from "./CopyButton"; -import Image from "next/image"; -import { usePolkachuSnapshots } from "@/lib/hooks"; -import { SkeletonSnapshotCard } from "./SkeletonLoader"; - -export interface ChainSnapshot { - name: string; - network: string; - latestBlock: number; - size: string; - prunedSize: string; - updated: string; - nodeVersion: string; - minimumGasPrice: string; - symbol: string; - denom: string; - description: string; - logo?: string; - blockExplorerUrl?: string; - github?: string; - services: { - rpc: boolean; - api: boolean; - grpc: boolean; - stateSync: boolean; - snapshot: boolean; - }; - endpoints: { - rpc?: string; - api?: string; - grpc?: string; - stateSync?: string; - snapshot?: string; - }; -} - -interface SnapshotCardProps { - chain: ChainSnapshot; - index?: number; -} - -const cardVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, - y: 0, - transition: { - duration: 0.5, - }, - }, -}; - -export const SnapshotCard = ({ chain, index = 0 }: SnapshotCardProps) => { - const chainId = chain.name.toLowerCase().replace(/\s+/g, ""); - const chainIdValue = chainId === "cosmoshub" ? "cosmos" : chainId; - const { data: snapshots, isLoading: isLoadingSnapshots } = - usePolkachuSnapshots({ - network: chainIdValue, - type: "mainnet", - }); - - // Show skeleton while loading - if (isLoadingSnapshots) { - return ; - } - - return ( - - -
- - {chain.logo ? ( - {chain.name} - ) : ( - - )} - -
- - {chain.name} - - - {snapshots?.snapshot.name} - -
-
- - -
- Latest Block: - - - #{snapshots?.snapshot.block_height.toLocaleString()} - - -
- -
- Node Version: - - - {chain.nodeVersion} - - -
- -
- Updated: - - {snapshots?.snapshot.time} - -
-
- - { - e.stopPropagation(); - e.preventDefault(); - }} - > - { - e.stopPropagation(); - e.preventDefault(); - if (snapshots?.snapshot.url) { - window.open(snapshots.snapshot.url, "_blank"); - } - }} - className="flex-1 bg-accent hover:bg-accent/90 text-white font-medium py-2.5 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center gap-2" - > - - Download - - -
- - ); -}; diff --git a/components/ui/SnapshotTable.tsx b/components/ui/SnapshotTable.tsx deleted file mode 100644 index 8604253..0000000 --- a/components/ui/SnapshotTable.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { PolkachuSnapshotsResponse } from "@/lib/hooks"; -import { DownloadIcon } from "../icons"; - -interface SnapshotTableProps { - snapshotData: PolkachuSnapshotsResponse; -} - -export const SnapshotTable = ({ snapshotData }: SnapshotTableProps) => ( -
-
- - - - - - - - - - - - - - - - - - - -
- Time - - Block Height - - Size - - Last Updated - - Action -
-
-
- {snapshotData.snapshot.time} -
-
-
- - #{snapshotData.snapshot.block_height} - - - - {snapshotData.snapshot.size} - - - - {snapshotData.snapshot.time} - - - - - Download - -
-
-
-); diff --git a/components/ui/StatusIndicator.tsx b/components/ui/StatusIndicator.tsx deleted file mode 100644 index f31851a..0000000 --- a/components/ui/StatusIndicator.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; - -interface StatusIndicatorProps { - isLive: boolean; - isLoading?: boolean; - className?: string; -} - -export const StatusIndicator = ({ - isLive, - isLoading = false, - className = "", -}: StatusIndicatorProps) => { - if (isLoading) { - return ( - - - Loading Data... - - ); - } - - return ( - - - {isLive ? ( - Live Data • Polkachu API - ) : ( - Cached Data • API Unavailable - )} - - ); -}; diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..36d0233 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface ButtonProps + extends React.ButtonHTMLAttributes { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'icon'; +} + +const Button = React.forwardRef( + ({ className, variant = 'default', size = 'default', ...props }, ref) => { + return ( + + + {error && ( +

{error}

+ )} + + {!user && ( +

+ { + e.preventDefault() + router.push('/login') + }} + > + Login + + {' '}for 5x faster downloads +

+ )} +
+ ) +} +``` + +## Security Model + +### Authentication Flow +```mermaid +graph TD + A[User visits site] --> B{Logged in?} + B -->|No| C[Anonymous/Free tier] + B -->|Yes| D[Check JWT token] + D -->|Valid| E[Premium tier] + D -->|Invalid| C + C --> F[50MB/s shared bandwidth] + E --> G[250MB/s shared bandwidth] + F --> H[Generate restricted URL] + G --> H + H --> I[5-min expiration + IP lock] +``` + +### Security Measures +1. **JWT-based authentication** with secure httpOnly cookies +2. **Pre-signed URLs** with 5-minute expiration +3. **IP-based restrictions** to prevent URL sharing +4. **Rate limiting** on URL generation (10 requests per minute) +5. **CORS configuration** to prevent unauthorized API access +6. **Input validation** on all API endpoints +7. **Secure headers** (CSP, HSTS, X-Frame-Options) + +### Environment Variables +```bash +# .env.local (Next.js) +MINIO_ENDPOINT=http://minio.apps.svc.cluster.local:9000 +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= +PREMIUM_USERNAME=premium_user +PREMIUM_PASSWORD_HASH= +JWT_SECRET= +NEXT_PUBLIC_API_URL=https://snapshots.bryanlabs.net + +# Kubernetes ConfigMap +BANDWIDTH_FREE_TOTAL=50 +BANDWIDTH_PREMIUM_TOTAL=250 +BANDWIDTH_MAX_TOTAL=500 +AUTH_SESSION_DURATION=7d +DOWNLOAD_URL_EXPIRY=5m +``` + +## Bandwidth Management + +### Bandwidth Allocation Strategy +``` +Total Available: 500MB/s (4Gbps) +├── Free Tier: 50MB/s (shared among all free users) +├── Premium Tier: 250MB/s (shared among all premium users) +└── Reserved: 200MB/s (buffer for system/peaks) +``` + +### Dynamic Bandwidth Adjustment +```typescript +// lib/bandwidth/manager.ts +interface BandwidthConfig { + freeTotal: number // 50 MB/s + premiumTotal: number // 250 MB/s + maxTotal: number // 500 MB/s +} + +class BandwidthManager { + private config: BandwidthConfig + private activeConnections: Map + + constructor(config: BandwidthConfig) { + this.config = config + this.activeConnections = new Map() + } + + calculateUserBandwidth(tier: 'free' | 'premium'): number { + const connections = Array.from(this.activeConnections.values()) + const tierConnections = connections.filter(c => c.tier === tier) + + if (tierConnections.length === 0) { + return tier === 'free' ? this.config.freeTotal : this.config.premiumTotal + } + + const totalForTier = tier === 'free' + ? this.config.freeTotal + : this.config.premiumTotal + + return Math.floor(totalForTier / tierConnections.length) + } + + registerConnection(id: string, tier: 'free' | 'premium') { + this.activeConnections.set(id, { + id, + tier, + startTime: Date.now(), + bandwidth: this.calculateUserBandwidth(tier) + }) + + // Rebalance all connections + this.rebalanceConnections() + } + + private rebalanceConnections() { + // Recalculate bandwidth for all active connections + for (const [id, conn] of this.activeConnections) { + conn.bandwidth = this.calculateUserBandwidth(conn.tier) + // Signal MinIO to update rate limit + this.updateMinioRateLimit(id, conn.bandwidth) + } + } +} +``` + +### MinIO Bandwidth Plugin +```lua +-- minio-bandwidth.lua +-- Custom Lua script for MinIO gateway to enforce dynamic bandwidth + +local tier_limits = { + free = 50 * 1024 * 1024, -- 50 MB/s total + premium = 250 * 1024 * 1024 -- 250 MB/s total +} + +local active_connections = {} + +function get_user_tier(headers) + local meta_tier = headers["X-Amz-Meta-User-Tier"] + return meta_tier or "free" +end + +function calculate_rate_limit(tier) + local count = 0 + for _, conn in pairs(active_connections) do + if conn.tier == tier then + count = count + 1 + end + end + + if count == 0 then + return tier_limits[tier] + end + + return math.floor(tier_limits[tier] / count) +end + +function on_request_start(request_id, headers) + local tier = get_user_tier(headers) + local rate_limit = calculate_rate_limit(tier) + + active_connections[request_id] = { + tier = tier, + start_time = os.time(), + rate_limit = rate_limit + } + + return { + rate_limit = rate_limit + } +end + +function on_request_end(request_id) + active_connections[request_id] = nil + -- Rebalance remaining connections + for id, conn in pairs(active_connections) do + conn.rate_limit = calculate_rate_limit(conn.tier) + end +end +``` + +## Monitoring & Observability + +### Key Metrics + +#### Application Metrics +```yaml +# Prometheus metrics exported by Next.js +snapshot_api_requests_total{endpoint, method, status} +snapshot_api_response_time_seconds{endpoint, method} +snapshot_downloads_initiated_total{chain_id, tier} +snapshot_auth_attempts_total{result} +snapshot_active_sessions{tier} +``` + +#### MinIO Metrics +```yaml +# Native MinIO metrics +minio_s3_requests_total{api, bucket} +minio_s3_traffic_sent_bytes{bucket} +minio_s3_request_duration_seconds{api, bucket} +minio_bucket_usage_total_bytes{bucket} +minio_cluster_capacity_usable_free_bytes +``` + +#### Custom Bandwidth Metrics +```yaml +# Custom exporter for bandwidth tracking +snapshot_bandwidth_current_bytes_per_second{tier} +snapshot_bandwidth_connections_active{tier} +snapshot_bandwidth_throttled_connections_total{tier} +snapshot_bandwidth_total_consumed_bytes +``` + +### Grafana Dashboard Configuration +```json +{ + "dashboard": { + "title": "Snapshot Service Overview", + "panels": [ + { + "title": "Current Bandwidth Usage", + "targets": [ + { + "expr": "sum(snapshot_bandwidth_current_bytes_per_second) by (tier)" + } + ] + }, + { + "title": "Active Downloads", + "targets": [ + { + "expr": "sum(snapshot_bandwidth_connections_active) by (tier)" + } + ] + }, + { + "title": "API Response Times (p95)", + "targets": [ + { + "expr": "histogram_quantile(0.95, snapshot_api_response_time_seconds)" + } + ] + }, + { + "title": "Storage Usage", + "targets": [ + { + "expr": "minio_bucket_usage_total_bytes{bucket=\"snapshots\"}" + } + ] + } + ] + } +} +``` + +### Alerting Rules +```yaml +groups: + - name: snapshot_service + rules: + - alert: BandwidthLimitExceeded + expr: sum(snapshot_bandwidth_current_bytes_per_second) > 500 * 1024 * 1024 + for: 1m + annotations: + summary: "Total bandwidth exceeds 500MB/s limit" + + - alert: StorageSpaceLow + expr: | + minio_cluster_capacity_usable_free_bytes / + minio_cluster_capacity_usable_total_bytes < 0.1 + for: 5m + annotations: + summary: "MinIO storage space below 10%" + + - alert: HighAPILatency + expr: | + histogram_quantile(0.95, snapshot_api_response_time_seconds) > 1 + for: 5m + annotations: + summary: "API p95 latency above 1 second" + + - alert: AuthenticationFailureSpike + expr: | + rate(snapshot_auth_attempts_total{result="failure"}[5m]) > 10 + annotations: + summary: "High rate of authentication failures" +``` + +## API Specifications + +### RESTful API Endpoints + +#### Public Endpoints +```yaml +GET /api/v1/chains + Description: List all chains with available snapshots + Response: + { + "chains": [ + { + "chain_id": "noble-1", + "name": "Noble", + "latest_snapshot": { + "block_height": 1234567, + "timestamp": "2024-01-10T10:00:00Z", + "size": "7.3GB" + } + } + ] + } + +GET /api/v1/chains/{chainId} + Description: Get details for specific chain + Response: + { + "chain_id": "noble-1", + "name": "Noble", + "snapshot_count": 1, + "total_size": "7.3GB", + "latest_block": 1234567 + } + +GET /api/v1/chains/{chainId}/snapshots + Description: List all snapshots for a chain + Response: + { + "chain_id": "noble-1", + "snapshots": [ + { + "filename": "noble-1-1234567.tar.lz4", + "block_height": 1234567, + "size": "7.3GB", + "timestamp": "2024-01-10T10:00:00Z", + "sha256": "abc123..." + } + ], + "latest": { + "filename": "latest.tar.lz4", + "size": "7.3GB" + } + } + +POST /api/v1/chains/{chainId}/download + Description: Generate pre-signed download URL + Request: + { + "filename": "latest.tar.lz4" // optional + } + Response: + { + "url": "https://...", + "expires_in": 300, + "tier": "free", + "bandwidth_limit": "50MB/s shared" + } +``` + +#### Authentication Endpoints +```yaml +POST /api/v1/auth/login + Description: Authenticate user + Request: + { + "username": "premium_user", + "password": "password" + } + Response: + { + "success": true, + "user": { + "username": "premium_user", + "tier": "premium" + } + } + +POST /api/v1/auth/logout + Description: Logout current user + Response: + { + "success": true + } + +GET /api/v1/auth/me + Description: Get current user info + Response: + { + "authenticated": true, + "user": { + "username": "premium_user", + "tier": "premium" + } + } +``` + +#### Health & Metrics +```yaml +GET /api/health + Description: Health check endpoint + Response: + { + "status": "healthy", + "version": "1.0.0", + "services": { + "minio": "connected", + "database": "connected" + } + } + +GET /api/metrics + Description: Prometheus metrics endpoint + Response: text/plain prometheus format +``` + +## User Experience + +### User Flows + +#### Anonymous User Flow +1. User visits homepage → Sees list of available chains +2. Clicks on chain → Views available snapshots with sizes +3. Clicks download → Sees bandwidth tier notice +4. Confirms download → Receives file at 50MB/s shared rate +5. Optional: Prompted to login for faster speeds + +#### Premium User Flow +1. User visits homepage → Clicks login +2. Enters credentials → Redirected to dashboard +3. Sees "Premium" badge and benefits +4. Downloads snapshot → Gets 250MB/s shared rate +5. Can download multiple files with session persistence + +### UI/UX Requirements +- **Responsive design** for mobile/tablet/desktop +- **Dark mode** support with system preference detection +- **Loading states** for all async operations +- **Error handling** with user-friendly messages +- **Download progress** indication (browser native) +- **Bandwidth indicator** showing current tier and speed + +### Accessibility Requirements +- **WCAG 2.1 AA** compliance +- **Keyboard navigation** for all interactive elements +- **Screen reader** friendly markup +- **Color contrast** ratios meeting standards +- **Focus indicators** clearly visible + +## Testing Strategy + +### Unit Testing +```typescript +// Example test for download URL generation +describe('Download API', () => { + it('should generate URL for free tier', async () => { + const response = await request(app) + .post('/api/v1/chains/noble-1/download') + .send({ filename: 'latest.tar.lz4' }) + + expect(response.status).toBe(200) + expect(response.body).toHaveProperty('url') + expect(response.body.tier).toBe('free') + expect(response.body.bandwidth_limit).toBe('50MB/s shared') + }) + + it('should generate URL for premium tier', async () => { + const token = await loginAsPremium() + + const response = await request(app) + .post('/api/v1/chains/noble-1/download') + .set('Cookie', `auth-token=${token}`) + .send({ filename: 'latest.tar.lz4' }) + + expect(response.status).toBe(200) + expect(response.body.tier).toBe('premium') + expect(response.body.bandwidth_limit).toBe('250MB/s shared') + }) +}) +``` + +### Integration Testing +```typescript +// Test MinIO integration +describe('MinIO Integration', () => { + it('should list snapshots from MinIO', async () => { + // Upload test snapshot + await uploadTestSnapshot('test-chain', 'test.tar.lz4') + + // Call API + const response = await request(app) + .get('/api/v1/chains/test-chain/snapshots') + + expect(response.body.snapshots).toHaveLength(1) + expect(response.body.snapshots[0].filename).toBe('test.tar.lz4') + }) +}) +``` + +### Load Testing +```javascript +// k6 load test script +import http from 'k6/http' +import { check, sleep } from 'k6' + +export const options = { + stages: [ + { duration: '2m', target: 10 }, // Ramp to 10 users + { duration: '5m', target: 50 }, // Ramp to 50 users + { duration: '2m', target: 100 }, // Ramp to 100 users + { duration: '5m', target: 100 }, // Stay at 100 users + { duration: '2m', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests under 500ms + http_req_failed: ['rate<0.1'], // Error rate under 10% + }, +} + +export default function () { + // List chains + const chains = http.get('https://snapshots.bryanlabs.net/api/v1/chains') + check(chains, { + 'chains status 200': (r) => r.status === 200, + }) + + // Generate download URL + const download = http.post( + 'https://snapshots.bryanlabs.net/api/v1/chains/noble-1/download', + JSON.stringify({ filename: 'latest.tar.lz4' }), + { headers: { 'Content-Type': 'application/json' } } + ) + check(download, { + 'download URL generated': (r) => r.status === 200, + 'URL present': (r) => JSON.parse(r.body).url !== undefined, + }) + + sleep(1) +} +``` + +### End-to-End Testing +```typescript +// Playwright E2E test +import { test, expect } from '@playwright/test' + +test('complete download flow', async ({ page }) => { + // Visit homepage + await page.goto('/') + await expect(page).toHaveTitle(/Blockchain Snapshots/) + + // Click on Noble chain + await page.click('text=Noble') + await expect(page).toHaveURL('/chains/noble-1') + + // Check snapshot is listed + await expect(page.locator('text=latest.tar.lz4')).toBeVisible() + + // Click download + await page.click('button:has-text("Download")') + + // Should see tier notice + await expect(page.locator('text=50MB/s shared')).toBeVisible() + + // Login link should be visible + await expect(page.locator('a:has-text("Login")')).toBeVisible() +}) + +test('premium user flow', async ({ page }) => { + // Login first + await page.goto('/login') + await page.fill('input[name="username"]', 'premium_user') + await page.fill('input[name="password"]', 'test_password') + await page.click('button[type="submit"]') + + // Should redirect to homepage + await expect(page).toHaveURL('/') + + // Should see premium badge + await expect(page.locator('text=Premium')).toBeVisible() + + // Download should show premium speed + await page.goto('/chains/noble-1') + await page.click('button:has-text("Download")') + + // No confirmation needed for premium + const downloadPromise = page.waitForEvent('download') + const download = await downloadPromise + expect(download.suggestedFilename()).toBe('noble-1-latest.tar.lz4') +}) +``` + +## Deployment Strategy + +### Kubernetes Deployment + +#### Namespace Structure +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: apps + labels: + name: apps + monitoring: enabled +``` + +#### Resource Definitions +```yaml +# Complete deployment manifests in cluster/apps/snapshot-website/ +# Complete MinIO manifests in cluster/apps/minio-snapshots/ + +# Key resources: +- Deployment (Next.js app, 2+ replicas) +- Deployment (MinIO, 2+ replicas) +- Service (ClusterIP for both) +- PVC (TopoLVM storage) +- ConfigMap (configuration) +- Secret (credentials) +- ServiceMonitor (Prometheus) +``` + +### CI/CD Pipeline +```yaml +# .github/workflows/snapshot-service.yml +name: Snapshot Service CI/CD + +on: + push: + branches: [main] + paths: + - 'snapshots/**' + - 'cluster/apps/snapshot-website/**' + - 'cluster/apps/minio-snapshots/**' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Install dependencies + run: cd snapshots && npm ci + + - name: Run tests + run: cd snapshots && npm test + + - name: Run E2E tests + run: cd snapshots && npm run test:e2e + + build: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build Docker image + run: | + cd snapshots + docker build -t ghcr.io/bryanlabs/snapshot-website:$GITHUB_SHA . + + - name: Push to registry + run: | + echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker push ghcr.io/bryanlabs/snapshot-website:$GITHUB_SHA + docker tag ghcr.io/bryanlabs/snapshot-website:$GITHUB_SHA ghcr.io/bryanlabs/snapshot-website:latest + docker push ghcr.io/bryanlabs/snapshot-website:latest + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Update Kubernetes manifests + run: | + cd cluster/apps/snapshot-website + sed -i "s|image: ghcr.io/bryanlabs/snapshot-website:.*|image: ghcr.io/bryanlabs/snapshot-website:$GITHUB_SHA|" deployment.yaml + + - name: Commit and push + run: | + git config --global user.name 'GitHub Actions' + git config --global user.email 'actions@github.com' + git commit -am "Update snapshot-website image to $GITHUB_SHA" + git push +``` + +### Rollout Strategy +1. **Blue-Green Deployment** for zero-downtime updates +2. **Canary Releases** for testing new features +3. **Rollback Plan** using Flux/ArgoCD +4. **Health Checks** before promoting new version + +## Configuration Management + +### Environment Configuration +```yaml +# cluster/apps/snapshot-website/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: snapshot-website-config +data: + # Bandwidth limits (MB/s) + BANDWIDTH_FREE_TOTAL: "50" + BANDWIDTH_PREMIUM_TOTAL: "250" + BANDWIDTH_MAX_TOTAL: "500" + + # MinIO connection + MINIO_ENDPOINT: "http://minio.apps.svc.cluster.local:9000" + MINIO_BUCKET: "snapshots" + + # Auth settings + AUTH_SESSION_DURATION: "7d" + DOWNLOAD_URL_EXPIRY: "5m" + + # Rate limiting + RATE_LIMIT_WINDOW: "60s" + RATE_LIMIT_MAX_REQUESTS: "10" +``` + +### Secret Management +```yaml +# cluster/apps/snapshot-website/secrets.yaml +apiVersion: v1 +kind: Secret +metadata: + name: snapshot-website-secrets +type: Opaque +stringData: + MINIO_ACCESS_KEY: "minioadmin" # Change in production + MINIO_SECRET_KEY: "minioadmin" # Change in production + PREMIUM_USERNAME: "premium_user" + PREMIUM_PASSWORD_HASH: "$2a$10$..." # bcrypt hash + JWT_SECRET: "your-secret-key" # Generate with openssl rand -hex 32 +``` + +### Dynamic Configuration Updates +```typescript +// lib/config/index.ts +interface Config { + bandwidth: { + freeTotal: number + premiumTotal: number + maxTotal: number + } + auth: { + sessionDuration: string + downloadUrlExpiry: string + } + rateLimit: { + windowMs: number + maxRequests: number + } +} + +class ConfigManager { + private config: Config + private watchInterval: NodeJS.Timeout + + constructor() { + this.config = this.loadConfig() + this.watchForChanges() + } + + private loadConfig(): Config { + return { + bandwidth: { + freeTotal: parseInt(process.env.BANDWIDTH_FREE_TOTAL || '50'), + premiumTotal: parseInt(process.env.BANDWIDTH_PREMIUM_TOTAL || '250'), + maxTotal: parseInt(process.env.BANDWIDTH_MAX_TOTAL || '500') + }, + auth: { + sessionDuration: process.env.AUTH_SESSION_DURATION || '7d', + downloadUrlExpiry: process.env.DOWNLOAD_URL_EXPIRY || '5m' + }, + rateLimit: { + windowMs: parseDuration(process.env.RATE_LIMIT_WINDOW || '60s'), + maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '10') + } + } + } + + private watchForChanges() { + // Re-read config every 30 seconds + this.watchInterval = setInterval(() => { + const newConfig = this.loadConfig() + if (JSON.stringify(newConfig) !== JSON.stringify(this.config)) { + console.log('Configuration changed, reloading...') + this.config = newConfig + this.notifyListeners() + } + }, 30000) + } +} +``` + +## Future Considerations + +### Phase 2 Enhancements +1. **Multiple User Tiers** + - Bronze: 10MB/s + - Silver: 50MB/s + - Gold: 100MB/s + - Platinum: Unlimited (within total cap) + +2. **Payment Integration** + - Stripe/PayPal integration + - Subscription management + - Usage-based billing + +3. **Advanced Features** + - Torrent generation for P2P distribution + - IPFS pinning for decentralized hosting + - Incremental snapshots + - Compression options (zstd, xz) + +4. **Geographic Distribution** + - CDN integration (Cloudflare R2) + - Multi-region MinIO clusters + - Edge caching + +### Scaling Considerations +1. **Horizontal Scaling** + - MinIO distributed mode (4+ nodes) + - Next.js with multiple replicas + - Redis for session storage + - PostgreSQL for user management + +2. **Performance Optimizations** + - SSD caching layer + - Bandwidth prediction algorithms + - Connection pooling + - HTTP/3 support + +3. **Monitoring Enhancements** + - Real-time bandwidth dashboard + - User analytics + - Cost tracking + - SLA monitoring + +### Security Enhancements +1. **Advanced Authentication** + - OAuth2/OIDC support + - 2FA for premium accounts + - API keys for automation + +2. **DDoS Protection** + - Rate limiting by IP + - Cloudflare integration + - Fail2ban for repeat offenders + +3. **Audit Logging** + - Complete download history + - Access patterns analysis + - Compliance reporting + +## Conclusion + +This PRD provides a comprehensive blueprint for building a production-grade blockchain snapshot service. The architecture prioritizes: + +- **Reliability** through redundant deployments and health checks +- **Performance** with bandwidth management and caching +- **Security** via authentication and access controls +- **Scalability** using cloud-native patterns +- **Observability** with extensive monitoring + +The implementation follows cloud-native best practices while remaining pragmatic for the initial scale of 10 chains and 10 users, with clear paths for growth to enterprise scale. \ No newline at end of file diff --git a/public/chains/cosmos.png b/public/chains/cosmos.png new file mode 100644 index 0000000..ed89410 --- /dev/null +++ b/public/chains/cosmos.png @@ -0,0 +1,4 @@ + + + Logo + \ No newline at end of file diff --git a/public/chains/juno.png b/public/chains/juno.png new file mode 100644 index 0000000..ed89410 --- /dev/null +++ b/public/chains/juno.png @@ -0,0 +1,4 @@ + + + Logo + \ No newline at end of file diff --git a/public/chains/osmosis.png b/public/chains/osmosis.png new file mode 100644 index 0000000..ed89410 --- /dev/null +++ b/public/chains/osmosis.png @@ -0,0 +1,4 @@ + + + Logo + \ No newline at end of file diff --git a/public/chains/placeholder.svg b/public/chains/placeholder.svg new file mode 100644 index 0000000..ed89410 --- /dev/null +++ b/public/chains/placeholder.svg @@ -0,0 +1,4 @@ + + + Logo + \ No newline at end of file diff --git a/scripts/setup-mock-data.sh b/scripts/setup-mock-data.sh new file mode 100755 index 0000000..c83364d --- /dev/null +++ b/scripts/setup-mock-data.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Create sample files with some content (1MB each for testing) +echo "Creating mock snapshot files..." + +# Cosmos Hub snapshots +dd if=/dev/zero of=mock-data/cosmos/cosmoshub-4-15234567.tar.lz4 bs=1M count=1 2>/dev/null +dd if=/dev/zero of=mock-data/cosmos/cosmoshub-4-15200000.tar.lz4 bs=1M count=1 2>/dev/null +dd if=/dev/zero of=mock-data/cosmos/cosmoshub-4-archive-15234567.tar.lz4 bs=1M count=2 2>/dev/null + +# Osmosis snapshots +dd if=/dev/zero of=mock-data/osmosis/osmosis-1-12345678.tar.lz4 bs=1M count=1 2>/dev/null +dd if=/dev/zero of=mock-data/osmosis/osmosis-1-12300000.tar.lz4 bs=1M count=1 2>/dev/null + +# Juno snapshots +dd if=/dev/zero of=mock-data/juno/juno-1-9876543.tar.lz4 bs=1M count=1 2>/dev/null +dd if=/dev/zero of=mock-data/juno/juno-1-9850000.tar.lz4 bs=1M count=1 2>/dev/null + +echo "Mock data created successfully!" \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d8b9323..fbf71d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,6 @@ "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "jest.setup.js"], + "exclude": ["node_modules", "e2e/**/*"] } diff --git a/types/user.ts b/types/user.ts new file mode 100644 index 0000000..8a2db98 --- /dev/null +++ b/types/user.ts @@ -0,0 +1,5 @@ +export interface User { + username: string; + isLoggedIn: boolean; + tier?: 'free' | 'premium'; +} \ No newline at end of file