diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e69de29..1d59b6c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -0,0 +1,161 @@ +name: Build and Test + +on: + push: + branches: [ main, develop, 'feature/**' ] + pull_request: + branches: [ main, develop ] + +jobs: + backend-build: + name: Backend Build & Test + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + working-directory: ./backend + run: npm install + + - name: Run linter + working-directory: ./backend + run: npm run lint --if-present + + - name: Run tests + working-directory: ./backend + run: npm test --if-present + + - name: Build + working-directory: ./backend + run: npm run build --if-present + + - name: Upload coverage reports + if: matrix.node-version == '18.x' + uses: codecov/codecov-action@v3 + with: + directory: ./backend/coverage + flags: backend + + backend-docker: + name: Backend Docker Build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: ./backend + push: false + tags: cloudkeep-backend:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + frontend-build: + name: Frontend Build & Test + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + working-directory: ./frontend + run: npm install + + - name: Run linter + working-directory: ./frontend + run: npm run lint --if-present + + - name: Run tests + working-directory: ./frontend + run: npm test --if-present + env: + CI: true + + - name: Build + working-directory: ./frontend + run: npm run build + env: + CI: true + + - name: Upload build artifacts + if: matrix.node-version == '18.x' + uses: actions/upload-artifact@v3 + with: + name: frontend-build + path: frontend/build + retention-days: 7 + + - name: Upload coverage reports + if: matrix.node-version == '18.x' + uses: codecov/codecov-action@v3 + with: + directory: ./frontend/coverage + flags: frontend + + frontend-docker: + name: Frontend Docker Build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: false + tags: cloudkeep-frontend:latest + cache-from: type=gha + cache-to: type=gha,mode=max + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy results to GitHub Security + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e69de29..019c58b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -0,0 +1,183 @@ +name: Deploy + +on: + push: + branches: + - main + - develop + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy to' + required: true + type: choice + options: + - dev + - staging + - production + +env: + AWS_REGION: us-east-1 + +jobs: + determine-environment: + name: Determine Environment + runs-on: ubuntu-latest + outputs: + environment: ${{ steps.set-env.outputs.environment }} + steps: + - name: Set environment + id: set-env + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "environment=${{ inputs.environment }}" >> $GITHUB_OUTPUT + elif [ "${{ github.ref }}" == "refs/heads/main" ]; then + echo "environment=production" >> $GITHUB_OUTPUT + elif [ "${{ github.ref }}" == "refs/heads/develop" ]; then + echo "environment=staging" >> $GITHUB_OUTPUT + else + echo "environment=dev" >> $GITHUB_OUTPUT + fi + + deploy-backend: + name: Deploy Backend + runs-on: ubuntu-latest + needs: determine-environment + environment: ${{ needs.determine-environment.outputs.environment }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.x' + + - name: Install dependencies + working-directory: ./backend + run: npm install + + - name: Install Serverless Framework + run: npm install -g serverless + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to AWS Lambda + working-directory: ./backend + run: | + serverless deploy --stage ${{ needs.determine-environment.outputs.environment }} --region ${{ env.AWS_REGION }} + + - name: Get deployment outputs + id: outputs + working-directory: ./backend + run: | + API_URL=$(serverless info --stage ${{ needs.determine-environment.outputs.environment }} --region ${{ env.AWS_REGION }} | grep "endpoint:" | awk '{print $2}') + echo "api_url=$API_URL" >> $GITHUB_OUTPUT + + - name: Store API URL + run: | + echo "Backend API URL: ${{ steps.outputs.outputs.api_url }}" + + deploy-frontend: + name: Deploy Frontend + runs-on: ubuntu-latest + needs: [determine-environment, deploy-backend] + environment: ${{ needs.determine-environment.outputs.environment }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18.x' + + - name: Install dependencies + working-directory: ./frontend + run: npm install + + - name: Build frontend + working-directory: ./frontend + run: npm run build + env: + CI: true + REACT_APP_API_URL: ${{ needs.deploy-backend.outputs.api_url }} + REACT_APP_STAGE: ${{ needs.determine-environment.outputs.environment }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Deploy to S3 + working-directory: ./frontend + run: | + aws s3 sync build/ s3://cloudkeep-frontend-${{ needs.determine-environment.outputs.environment }} --delete + + - name: Invalidate CloudFront cache + if: needs.determine-environment.outputs.environment == 'production' + run: | + aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*" + + deploy-docker: + name: Deploy Docker Images + runs-on: ubuntu-latest + needs: determine-environment + if: needs.determine-environment.outputs.environment == 'production' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push backend image + uses: docker/build-push-action@v5 + with: + context: ./backend + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/cloudkeep-backend:latest + ${{ secrets.DOCKER_USERNAME }}/cloudkeep-backend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push frontend image + uses: docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: | + ${{ secrets.DOCKER_USERNAME }}/cloudkeep-frontend:latest + ${{ secrets.DOCKER_USERNAME }}/cloudkeep-frontend:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + notify: + name: Notify Deployment Status + runs-on: ubuntu-latest + needs: [determine-environment, deploy-backend, deploy-frontend] + if: always() + + steps: + - name: Send notification + run: | + echo "Deployment to ${{ needs.determine-environment.outputs.environment }} completed" + echo "Backend: ${{ needs.deploy-backend.result }}" + echo "Frontend: ${{ needs.deploy-frontend.result }}" diff --git a/README.md b/README.md index 7cf2371..32f38b0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,926 @@ -# cloudkeep -CouldKeep is a lightweight, scalable cloud storage solution that allows users to securely upload, store, and access their files from anywhere. Designed to be a modern alternative to services like Dropbox, CouldKeep emphasizes simplicity, data privacy, and extensibility. +# CloudKeep + +CloudKeep is a lightweight, scalable cloud storage solution that allows users to securely upload, store, and access their files from anywhere. Designed to be a modern alternative to services like Dropbox, CloudKeep emphasizes simplicity, data privacy, and extensibility. + +## Table of Contents + +- [Features](#features) +- [Architecture](#architecture) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Running Locally](#running-locally) +- [Running with Docker](#running-with-docker) +- [Testing](#testing) +- [Deployment](#deployment) +- [API Documentation](#api-documentation) +- [Environment Variables](#environment-variables) +- [Project Structure](#project-structure) +- [Contributing](#contributing) + +## Features + +- **Secure File Storage**: Upload files to AWS S3 with encryption +- **File Management**: List, download, delete, and share files +- **User Authentication**: Token-based authorization +- **Serverless Architecture**: Scalable AWS Lambda functions +- **Modern UI**: React-based responsive interface +- **RESTful API**: Well-documented API endpoints +- **Docker Support**: Containerized deployment option + +## Architecture + +**Backend:** +- Node.js 18 with Express +- AWS Lambda for serverless functions +- AWS S3 for file storage +- DynamoDB for metadata storage +- Serverless Framework for deployment + +**Frontend:** +- React 18 +- Modern responsive design +- Nginx for production serving + +## Prerequisites + +Before you begin, ensure you have the following installed: + +- **Node.js** (v18 or higher) - [Download](https://nodejs.org/) +- **npm** (v9 or higher) - Comes with Node.js +- **Docker** (optional, for containerized deployment) - [Download](https://www.docker.com/) +- **AWS Account** (for deployment) - [Sign up](https://aws.amazon.com/) +- **AWS CLI** (for deployment) - [Install Guide](https://aws.amazon.com/cli/) + +## Installation + +### 1. Clone the Repository + +```bash +git clone https://github.com/AbgaryanNver/cloudkeep.git +cd cloudkeep +``` + +### 2. Install Backend Dependencies + +```bash +cd backend +npm install +cd .. +``` + +### 3. Install Frontend Dependencies + +```bash +cd frontend +npm install +cd .. +``` + +## Running Locally + +### Backend + +#### Option 1: Using Express Server (Recommended for Development) + +```bash +cd backend + +# Set environment variables (create .env file) +cat > .env << EOF +PORT=3000 +BUCKET_NAME=cloudkeep-files-dev +DYNAMODB_TABLE=cloudkeep-metadata-dev +AWS_REGION=us-east-1 +EOF + +# Start the server +npm start +``` + +The backend API will be available at `http://localhost:3000` + +#### Option 2: Using Serverless Offline + +```bash +cd backend + +# Install serverless globally (if not already installed) +npm install -g serverless + +# Start serverless offline +npm run local +``` + +The API will be available at `http://localhost:3000` + +### Frontend + +```bash +cd frontend + +# Create .env file (optional) +cat > .env << EOF +REACT_APP_API_URL=http://localhost:3000 +EOF + +# Start development server +npm start +``` + +The frontend will be available at `http://localhost:3000` (or `http://localhost:3001` if backend is running on 3000) + +### Testing the Application + +1. Open your browser to `http://localhost:3000` (frontend) +2. Click "Upload File" to upload a file +3. View your uploaded files in the list +4. Click the delete button to remove files + +**Note:** For local development without AWS, you'll need to set up LocalStack or mock AWS services. + +## Running with Docker + +### Build and Run Backend + +```bash +cd backend + +# Build the Docker image +docker build -t cloudkeep-backend . + +# Run the container +docker run -p 3000:3000 \ + -e BUCKET_NAME=cloudkeep-files-dev \ + -e DYNAMODB_TABLE=cloudkeep-metadata-dev \ + -e AWS_REGION=us-east-1 \ + -e AWS_ACCESS_KEY_ID=your_access_key \ + -e AWS_SECRET_ACCESS_KEY=your_secret_key \ + cloudkeep-backend +``` + +### Build and Run Frontend + +```bash +cd frontend + +# Build the Docker image +docker build -t cloudkeep-frontend . + +# Run the container +docker run -p 80:80 cloudkeep-frontend +``` + +### Using Docker Compose (Recommended) + +Create a `docker-compose.yml` in the root directory: + +```yaml +version: '3.8' + +services: + backend: + build: ./backend + ports: + - "3000:3000" + environment: + - BUCKET_NAME=cloudkeep-files-dev + - DYNAMODB_TABLE=cloudkeep-metadata-dev + - AWS_REGION=us-east-1 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 3s + retries: 3 + + frontend: + build: ./frontend + ports: + - "80:80" + depends_on: + - backend + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/health"] + interval: 30s + timeout: 3s + retries: 3 +``` + +Then run: + +```bash +docker-compose up -d +``` + +## Testing + +### Backend Tests + +```bash +cd backend + +# Run tests +npm test + +# Run tests with coverage +npm test -- --coverage + +# Run tests in watch mode +npm test -- --watch +``` + +### Frontend Tests + +```bash +cd frontend + +# Run tests +npm test + +# Run tests with coverage +npm test -- --coverage + +# Run all tests (non-watch mode) +npm test -- --watchAll=false +``` + +### Linting + +```bash +# Backend +cd backend +npm run lint + +# Frontend +cd frontend +npm run lint +``` + +## Deployment + +CloudKeep uses **Terraform** for infrastructure provisioning and **Serverless Framework** for Lambda deployment. This section provides complete step-by-step instructions. + +### AWS Infrastructure Setup (Terraform) + +#### Prerequisites + +1. **Terraform** (>= 1.0) + ```bash + # macOS + brew install terraform + + # Linux + wget https://releases.hashicorp.com/terraform/1.6.0/terraform_1.6.0_linux_amd64.zip + unzip terraform_1.6.0_linux_amd64.zip + sudo mv terraform /usr/local/bin/ + ``` + +2. **AWS CLI** configured with credentials + ```bash + aws configure + # Enter your AWS Access Key ID + # Enter your AWS Secret Access Key + # Default region: us-east-1 + # Default output format: json + ``` + +#### Step 1: Create Terraform State Backend + +Before deploying infrastructure, create S3 bucket and DynamoDB table for Terraform state: + +```bash +# Create S3 bucket for Terraform state +aws s3 mb s3://cloudkeep-terraform-state --region us-east-1 + +# Enable versioning on state bucket +aws s3api put-bucket-versioning \ + --bucket cloudkeep-terraform-state \ + --versioning-configuration Status=Enabled + +# Enable encryption +aws s3api put-bucket-encryption \ + --bucket cloudkeep-terraform-state \ + --server-side-encryption-configuration '{ + "Rules": [{ + "ApplyServerSideEncryptionByDefault": { + "SSEAlgorithm": "AES256" + } + }] + }' + +# Create DynamoDB table for state locking +aws dynamodb create-table \ + --table-name terraform-state-lock \ + --attribute-definitions AttributeName=LockID,AttributeType=S \ + --key-schema AttributeName=LockID,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --region us-east-1 +``` + +#### Step 2: Initialize Terraform + +```bash +cd terraform +terraform init +``` + +Expected output: +``` +Initializing modules... +Initializing the backend... +Terraform has been successfully initialized! +``` + +#### Step 3: Review Infrastructure Plan + +```bash +# Development environment +terraform plan -var-file=environments/dev/terraform.tfvars + +# Staging environment +terraform plan -var-file=environments/staging/terraform.tfvars + +# Production environment +terraform plan -var-file=environments/prod/terraform.tfvars +``` + +This will show you all resources that will be created: +- VPC with public/private subnets +- NAT Gateways +- Security Groups +- Cognito User Pool +- S3 Bucket +- DynamoDB Table +- ElastiCache Cluster +- Application Load Balancer +- API Gateway + +#### Step 4: Deploy Infrastructure + +```bash +# Deploy development infrastructure +terraform apply -var-file=environments/dev/terraform.tfvars + +# Type 'yes' when prompted to confirm +``` + +Deployment takes approximately **10-15 minutes**. Resources created: +- ✅ VPC across 3 availability zones +- ✅ 3 NAT Gateways +- ✅ Cognito User Pool +- ✅ S3 bucket with encryption +- ✅ DynamoDB table +- ✅ ElastiCache Redis cluster +- ✅ Application Load Balancer +- ✅ Security Groups +- ✅ VPC Endpoints + +#### Step 5: Save Terraform Outputs + +```bash +# View all outputs +terraform output + +# Save to file for reference +terraform output -json > terraform-outputs.json + +# Get specific values +terraform output cognito_user_pool_id +terraform output cognito_user_pool_client_id +terraform output s3_bucket_name +terraform output dynamodb_table_name +terraform output api_gateway_url +terraform output elasticache_endpoint +terraform output alb_dns_name +``` + +**Important**: Save these values - you'll need them for backend and frontend configuration. + +### Backend Deployment (Serverless Framework) + +#### Step 1: Configure Backend Environment + +Create `backend/.env` file with Terraform outputs: + +```bash +cd ../backend + +cat > .env << EOF +# AWS Configuration +AWS_REGION=us-east-1 +NODE_ENV=production + +# From Terraform outputs +USER_POOL_ID=$(cd ../terraform && terraform output -raw cognito_user_pool_id) +USER_POOL_CLIENT_ID=$(cd ../terraform && terraform output -raw cognito_user_pool_client_id) +BUCKET_NAME=$(cd ../terraform && terraform output -raw s3_bucket_name) +DYNAMODB_TABLE=$(cd ../terraform && terraform output -raw dynamodb_table_name) +ELASTICACHE_ENDPOINT=$(cd ../terraform && terraform output -raw elasticache_endpoint) +EOF +``` + +#### Step 2: Update Serverless Configuration + +The `serverless.yml` should reference the Terraform-created resources: + +```yaml +# This configuration is already set up in backend/serverless.yml +# Verify it matches your Terraform outputs +``` + +#### Step 3: Install Serverless Framework + +```bash +npm install -g serverless +``` + +#### Step 4: Deploy Lambda Functions + +```bash +# Deploy to development +serverless deploy --stage dev --region us-east-1 + +# Deploy to staging +serverless deploy --stage staging --region us-east-1 + +# Deploy to production +serverless deploy --stage production --region us-east-1 +``` + +Deployment creates: +- ✅ 6 Lambda functions (upload, download, list, delete, share, authorizer) +- ✅ API Gateway endpoints +- ✅ Lambda execution roles +- ✅ CloudWatch log groups + +#### Step 5: Test Backend API + +```bash +# Get API endpoint from deployment output +API_URL="" + +# Test health endpoint +curl $API_URL/health + +# Expected response: +# {"status":"healthy","service":"cloudkeep-backend","timestamp":"..."} +``` + +### Frontend Deployment + +#### Step 1: Configure Frontend + +Create `frontend/src/aws-config.js`: + +```bash +cd ../frontend + +cat > src/aws-config.js << 'EOF' +const awsconfig = { + Auth: { + Cognito: { + region: 'us-east-1', + userPoolId: process.env.REACT_APP_USER_POOL_ID, + userPoolClientId: process.env.REACT_APP_USER_POOL_CLIENT_ID, + } + }, + API: { + endpoints: [ + { + name: 'CloudKeepAPI', + endpoint: process.env.REACT_APP_API_URL, + region: 'us-east-1' + } + ] + } +}; + +export default awsconfig; +EOF +``` + +#### Step 2: Create Environment File + +```bash +# Get values from Terraform +cd ../terraform + +cat > ../frontend/.env.production << EOF +REACT_APP_USER_POOL_ID=$(terraform output -raw cognito_user_pool_id) +REACT_APP_USER_POOL_CLIENT_ID=$(terraform output -raw cognito_user_pool_client_id) +REACT_APP_API_URL=$(cd ../backend && serverless info --stage production | grep "endpoint:" | awk '{print $2}') +REACT_APP_STAGE=production +EOF +``` + +#### Step 3: Build Frontend + +```bash +cd ../frontend + +# Install dependencies +npm install + +# Build for production +npm run build +``` + +#### Step 4: Deploy to S3 + +```bash +# Get S3 bucket name from Terraform +S3_BUCKET=$(cd ../terraform && terraform output -raw s3_bucket_name) + +# Sync build to S3 +aws s3 sync build/ s3://$S3_BUCKET --delete + +# Set proper content types +aws s3 cp s3://$S3_BUCKET s3://$S3_BUCKET \ + --recursive \ + --exclude "*" \ + --include "*.html" \ + --content-type "text/html" \ + --metadata-directive REPLACE + +# Configure bucket for static website hosting +aws s3 website s3://$S3_BUCKET \ + --index-document index.html \ + --error-document index.html +``` + +#### Step 5: Access Application + +```bash +# Get ALB DNS name +ALB_DNS=$(cd ../terraform && terraform output -raw alb_dns_name) + +echo "Application URL: http://$ALB_DNS" +``` + +### Post-Deployment Configuration + +#### Configure Cognito + +1. **Create Test User**: + ```bash + aws cognito-idp admin-create-user \ + --user-pool-id \ + --username testuser@example.com \ + --user-attributes Name=email,Value=testuser@example.com \ + --temporary-password TempPass123! + ``` + +2. **Confirm User** (for testing): + ```bash + aws cognito-idp admin-set-user-password \ + --user-pool-id \ + --username testuser@example.com \ + --password MyNewPass123! \ + --permanent + ``` + +#### Configure Custom Domain (Optional) + +1. **Request ACM Certificate**: + ```bash + aws acm request-certificate \ + --domain-name cloudkeep.yourdomain.com \ + --validation-method DNS \ + --region us-east-1 + ``` + +2. **Update Terraform** with certificate ARN: + ```hcl + # In terraform/environments/prod/terraform.tfvars + acm_certificate_arn = "arn:aws:acm:us-east-1:..." + ``` + +3. **Re-apply Terraform**: + ```bash + terraform apply -var-file=environments/prod/terraform.tfvars + ``` + +4. **Create Route53 Record**: + ```bash + aws route53 change-resource-record-sets \ + --hosted-zone-id \ + --change-batch file://dns-record.json + ``` + +### Automated Deployment with GitHub Actions + +The project includes CI/CD workflows: + +#### Build Workflow (`.github/workflows/build.yml`) +Runs on every push/PR: +- ✅ Builds and tests backend and frontend +- ✅ Creates Docker images +- ✅ Runs security scans +- ✅ Linting and code quality checks + +#### Deploy Workflow (`.github/workflows/deploy.yml`) +Runs on main/develop branches: +- ✅ Deploys backend to AWS Lambda +- ✅ Deploys frontend to S3 +- ✅ Publishes Docker images +- ✅ Invalidates CloudFront cache + +#### Required GitHub Secrets + +Configure these in your repository settings (Settings → Secrets → Actions): + +```bash +# AWS Credentials +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= + +# Docker Hub (for Docker image publishing) +DOCKER_USERNAME= +DOCKER_PASSWORD= + +# CloudFront (if using) +CLOUDFRONT_DISTRIBUTION_ID= + +# Cognito (from Terraform outputs) +USER_POOL_ID= +USER_POOL_CLIENT_ID= +``` + +### Monitoring and Logs + +#### View Lambda Logs + +```bash +# List log groups +aws logs describe-log-groups --log-group-name-prefix /aws/lambda/cloudkeep + +# Tail logs for upload function +serverless logs -f uploadFile --tail --stage production + +# View last 100 lines +serverless logs -f uploadFile --tail --stage production --startTime 1h +``` + +#### View API Gateway Logs + +```bash +# Enable API Gateway logging (one-time setup) +aws apigateway update-stage \ + --rest-api-id \ + --stage-name production \ + --patch-operations op=replace,path=/accessLogSettings/destinationArn,value= +``` + +#### CloudWatch Dashboard + +Create a dashboard to monitor: +- Lambda invocations and errors +- API Gateway requests and latency +- S3 bucket operations +- DynamoDB read/write capacity +- ElastiCache hit/miss ratio + +### Cost Estimation + +**Monthly costs for development environment:** + +| Service | Usage | Est. Cost | +|---------|-------|-----------| +| VPC & NAT Gateway | 1 NAT (dev) | ~$32 | +| ElastiCache | t3.micro | ~$12 | +| S3 | 10 GB storage | ~$0.23 | +| DynamoDB | On-demand, low traffic | ~$1-5 | +| Lambda | 1M requests | ~$0.20 | +| API Gateway | 1M requests | ~$3.50 | +| CloudWatch Logs | 5 GB | ~$2.50 | +| **Total** | | **~$51/month** | + +**Production environment** (3 NAT Gateways, larger ElastiCache): **~$150-200/month** + +**Cost optimization tips:** +- Use single NAT Gateway for dev +- Use t3.micro for ElastiCache in dev +- Enable S3 lifecycle policies +- Use DynamoDB on-demand billing +- Set CloudWatch log retention to 7 days for dev + +## API Documentation + +### Authentication + +All endpoints (except health check) require a Bearer token: + +``` +Authorization: Bearer cloudkeep-{userId} +``` + +For development, you can also use the `x-user-id` header: + +``` +x-user-id: demo-user +``` + +### Endpoints + +#### Health Check + +```http +GET /health +``` + +Response: +```json +{ + "status": "healthy", + "service": "cloudkeep-backend", + "timestamp": "2024-01-01T00:00:00.000Z" +} +``` + +#### Upload File + +```http +POST /upload +Content-Type: application/json +Authorization: Bearer cloudkeep-{userId} + +{ + "fileName": "document.pdf", + "fileContent": "base64_encoded_content", + "contentType": "application/pdf", + "fileSize": 12345 +} +``` + +Response: +```json +{ + "message": "File uploaded successfully", + "fileId": "uuid", + "fileName": "document.pdf", + "uploadDate": 1234567890 +} +``` + +#### List Files + +```http +GET /files?limit=50&lastKey=encoded_key +Authorization: Bearer cloudkeep-{userId} +``` + +Response: +```json +{ + "files": [ + { + "fileId": "uuid", + "fileName": "document.pdf", + "fileSize": 12345, + "contentType": "application/pdf", + "uploadDate": 1234567890, + "shared": false + } + ], + "count": 1, + "lastKey": "encoded_key_for_pagination" +} +``` + +#### Download File + +```http +GET /download/{fileId} +Authorization: Bearer cloudkeep-{userId} +``` + +Response: +```json +{ + "downloadUrl": "https://s3.amazonaws.com/...", + "fileName": "document.pdf", + "fileSize": 12345, + "contentType": "application/pdf", + "expiresIn": 3600 +} +``` + +#### Delete File + +```http +DELETE /files/{fileId} +Authorization: Bearer cloudkeep-{userId} +``` + +Response: +```json +{ + "message": "File deleted successfully", + "fileId": "uuid", + "fileName": "document.pdf" +} +``` + +#### Share File + +```http +POST /share/{fileId} +Content-Type: application/json +Authorization: Bearer cloudkeep-{userId} + +{ + "expiresIn": 86400 +} +``` + +Response: +```json +{ + "message": "Share link generated successfully", + "shareUrl": "https://s3.amazonaws.com/...", + "fileName": "document.pdf", + "expiresIn": 86400, + "expiresAt": 1234567890000 +} +``` + +## Environment Variables + +### Backend + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `PORT` | Server port | `3000` | No | +| `BUCKET_NAME` | S3 bucket name | - | Yes | +| `DYNAMODB_TABLE` | DynamoDB table name | - | Yes | +| `AWS_REGION` | AWS region | `us-east-1` | No | +| `AWS_ACCESS_KEY_ID` | AWS access key | - | Yes (for deployment) | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - | Yes (for deployment) | + +### Frontend + +| Variable | Description | Default | Required | +|----------|-------------|---------|----------| +| `REACT_APP_API_URL` | Backend API URL | `http://localhost:3000` | No | +| `REACT_APP_STAGE` | Environment stage | `development` | No | + +## Project Structure + +``` +cloudkeep/ +├── backend/ +│ ├── handlers/ # Lambda function handlers +│ │ ├── upload.js # File upload handler +│ │ ├── download.js # File download handler +│ │ ├── list.js # List files handler +│ │ ├── delete.js # Delete file handler +│ │ ├── share.js # Share file handler +│ │ └── authorizer.js # API authorizer +│ ├── __tests__/ # Test files +│ │ └── handlers.test.js # Handler tests +│ ├── index.js # Express server for Docker +│ ├── package.json # Dependencies and scripts +│ ├── serverless.yml # Serverless configuration +│ ├── Dockerfile # Docker configuration +│ └── .gitignore # Git ignore patterns +├── frontend/ +│ ├── public/ # Static files +│ │ └── index.html # HTML template +│ ├── src/ # React source code +│ │ ├── App.js # Main application component +│ │ ├── App.css # Application styles +│ │ ├── App.test.js # Component tests +│ │ ├── index.js # React entry point +│ │ ├── index.css # Global styles +│ │ └── setupTests.js # Test configuration +│ ├── package.json # Dependencies and scripts +│ ├── nginx.conf # Nginx configuration +│ ├── Dockerfile # Docker configuration +│ └── .gitignore # Git ignore patterns +├── .github/ +│ └── workflows/ # GitHub Actions workflows +│ ├── build.yml # Build and test workflow +│ └── deploy.yml # Deployment workflow +└── README.md # This file +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License. + +## Support + +For issues and questions: +- Open an issue on [GitHub](https://github.com/AbgaryanNver/cloudkeep/issues) +- Check existing documentation + +--- + +**CloudKeep** - Secure, Simple, Scalable Cloud Storage diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json new file mode 100644 index 0000000..f8ccff3 --- /dev/null +++ b/backend/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "env": { + "node": true, + "es2021": true, + "jest": true + }, + "extends": "eslint:recommended", + "parserOptions": { + "ecmaVersion": 12 + }, + "rules": { + "no-console": "off", + "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + } +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..e632872 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules/ +package-lock.json + +# Serverless +.serverless/ +.serverless_plugins/ + +# Environment variables +.env +.env.local +.env.*.local + +# Testing +coverage/ +.nyc_output/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ diff --git a/backend/Dockerfile b/backend/Dockerfile index e69de29..e8b01d1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -0,0 +1,24 @@ +# Backend Dockerfile for CloudKeep +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install --only=production + +# Copy source code +COPY . . + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# Start the application +CMD ["node", "index.js"] diff --git a/backend/__tests__/handlers.test.js b/backend/__tests__/handlers.test.js new file mode 100644 index 0000000..9c793e0 --- /dev/null +++ b/backend/__tests__/handlers.test.js @@ -0,0 +1,74 @@ +// Mock aws-jwt-verify before requiring the authorizer +jest.mock('aws-jwt-verify', () => ({ + CognitoJwtVerifier: { + create: jest.fn(() => ({ + verify: jest.fn() + })) + } +})); + +const authorizer = require('../handlers/authorizer'); + +describe('CloudKeep Backend Tests', () => { + beforeEach(() => { + // Set development environment for tests + process.env.NODE_ENV = 'development'; + }); + + describe('Authorizer', () => { + test('should reject requests without authorization token', async () => { + const event = { + authorizationToken: null, + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/prod/GET/files' + }; + + await expect(authorizer.handler(event)).rejects.toThrow('Unauthorized'); + }); + + test('should reject invalid tokens', async () => { + const event = { + authorizationToken: 'Bearer invalid-token', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/prod/GET/files' + }; + + await expect(authorizer.handler(event)).rejects.toThrow('Unauthorized'); + }); + + test('should accept valid tokens', async () => { + const event = { + authorizationToken: 'Bearer cloudkeep-testuser', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/prod/GET/files' + }; + + const result = await authorizer.handler(event); + + expect(result).toBeDefined(); + expect(result.principalId).toBe('testuser'); + expect(result.policyDocument).toBeDefined(); + expect(result.policyDocument.Statement[0].Effect).toBe('Allow'); + }); + + test('should include context in authorization response', async () => { + const event = { + authorizationToken: 'Bearer cloudkeep-contextuser', + methodArn: 'arn:aws:execute-api:us-east-1:123456789012:abcdef123/prod/GET/files' + }; + + const result = await authorizer.handler(event); + + expect(result.context).toBeDefined(); + expect(result.context.userId).toBe('contextuser'); + expect(result.context.timestamp).toBeDefined(); + }); + }); + + describe('API Health', () => { + test('should have valid package.json', () => { + const packageJson = require('../package.json'); + + expect(packageJson.name).toBe('cloudkeep-backend'); + expect(packageJson.version).toBeDefined(); + expect(packageJson.dependencies).toBeDefined(); + }); + }); +}); diff --git a/backend/handlers/authorizer.js b/backend/handlers/authorizer.js new file mode 100644 index 0000000..8042f0a --- /dev/null +++ b/backend/handlers/authorizer.js @@ -0,0 +1,77 @@ +const { CognitoJwtVerifier } = require("aws-jwt-verify"); + +// Configure the verifier +const verifier = process.env.USER_POOL_ID ? CognitoJwtVerifier.create({ + userPoolId: process.env.USER_POOL_ID, + tokenUse: "access", + clientId: process.env.USER_POOL_CLIENT_ID, +}) : null; + +exports.handler = async (event) => { + try { + const token = event.authorizationToken; + + if (!token) { + throw new Error('Unauthorized'); + } + + // Extract token from "Bearer " format + const tokenValue = token.replace('Bearer ', ''); + + // For development/testing: Simple token validation + if (process.env.NODE_ENV === 'development' && tokenValue.startsWith('cloudkeep-')) { + const userId = tokenValue.replace('cloudkeep-', '') || 'demo-user'; + return generatePolicy(userId, 'Allow', event.methodArn); + } + + // Production: Verify Cognito JWT token + if (verifier) { + try { + const payload = await verifier.verify(tokenValue); + const userId = payload.sub || payload.username; + return generatePolicy(userId, 'Allow', event.methodArn); + } catch (error) { + console.error('Token verification failed:', error); + throw new Error('Unauthorized'); + } + } else { + // Fallback for simple token validation + if (!tokenValue.startsWith('cloudkeep-')) { + throw new Error('Unauthorized'); + } + const userId = tokenValue.replace('cloudkeep-', '') || 'demo-user'; + return generatePolicy(userId, 'Allow', event.methodArn); + } + } catch (error) { + console.error('Authorization error:', error); + throw new Error('Unauthorized'); + } +}; + +function generatePolicy(principalId, effect, resource) { + const authResponse = { + principalId + }; + + if (effect && resource) { + const policyDocument = { + Version: '2012-10-17', + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: effect, + Resource: resource + } + ] + }; + authResponse.policyDocument = policyDocument; + } + + // Optional: Add additional context + authResponse.context = { + userId: principalId, + timestamp: new Date().toISOString() + }; + + return authResponse; +} diff --git a/backend/handlers/delete.js b/backend/handlers/delete.js new file mode 100644 index 0000000..29d12fb --- /dev/null +++ b/backend/handlers/delete.js @@ -0,0 +1,90 @@ +const { S3Client, DeleteObjectCommand } = require('@aws-sdk/client-s3'); +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { DynamoDBDocumentClient, GetCommand, UpdateCommand } = require('@aws-sdk/lib-dynamodb'); + +const s3Client = new S3Client({}); +const dynamoClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(dynamoClient); + +const BUCKET_NAME = process.env.BUCKET_NAME; +const TABLE_NAME = process.env.DYNAMODB_TABLE; + +exports.handler = async (event) => { + try { + const userId = event.requestContext.authorizer.principalId; + const fileId = event.pathParameters.fileId; + + if (!fileId) { + return { + statusCode: 400, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Missing fileId parameter' }) + }; + } + + // Get file metadata + const result = await docClient.send(new GetCommand({ + TableName: TABLE_NAME, + Key: { userId, fileId } + })); + + if (!result.Item) { + return { + statusCode: 404, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'File not found' }) + }; + } + + const fileMetadata = result.Item; + + // Delete from S3 + await s3Client.send(new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: fileMetadata.s3Key + })); + + // Soft delete in DynamoDB (mark as deleted) + await docClient.send(new UpdateCommand({ + TableName: TABLE_NAME, + Key: { userId, fileId }, + UpdateExpression: 'SET #status = :deleted, deletedAt = :deletedAt', + ExpressionAttributeNames: { + '#status': 'status' + }, + ExpressionAttributeValues: { + ':deleted': 'deleted', + ':deletedAt': Date.now() + } + })); + + return { + statusCode: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'File deleted successfully', + fileId, + fileName: fileMetadata.fileName + }) + }; + } catch (error) { + console.error('Delete error:', error); + return { + statusCode: 500, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Failed to delete file', details: error.message }) + }; + } +}; diff --git a/backend/handlers/download.js b/backend/handlers/download.js new file mode 100644 index 0000000..cfa08e5 --- /dev/null +++ b/backend/handlers/download.js @@ -0,0 +1,81 @@ +const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { DynamoDBDocumentClient, GetCommand } = require('@aws-sdk/lib-dynamodb'); + +const s3Client = new S3Client({}); +const dynamoClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(dynamoClient); + +const BUCKET_NAME = process.env.BUCKET_NAME; +const TABLE_NAME = process.env.DYNAMODB_TABLE; + +exports.handler = async (event) => { + try { + const userId = event.requestContext.authorizer.principalId; + const fileId = event.pathParameters.fileId; + + if (!fileId) { + return { + statusCode: 400, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Missing fileId parameter' }) + }; + } + + // Get file metadata from DynamoDB + const result = await docClient.send(new GetCommand({ + TableName: TABLE_NAME, + Key: { userId, fileId } + })); + + if (!result.Item) { + return { + statusCode: 404, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'File not found' }) + }; + } + + const fileMetadata = result.Item; + + // Generate presigned URL for download + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: fileMetadata.s3Key + }); + + const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + + return { + statusCode: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + downloadUrl: presignedUrl, + fileName: fileMetadata.fileName, + fileSize: fileMetadata.fileSize, + contentType: fileMetadata.contentType, + expiresIn: 3600 + }) + }; + } catch (error) { + console.error('Download error:', error); + return { + statusCode: 500, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Failed to generate download URL', details: error.message }) + }; + } +}; diff --git a/backend/handlers/list.js b/backend/handlers/list.js new file mode 100644 index 0000000..9dddf9b --- /dev/null +++ b/backend/handlers/list.js @@ -0,0 +1,70 @@ +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { DynamoDBDocumentClient, QueryCommand } = require('@aws-sdk/lib-dynamodb'); + +const dynamoClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(dynamoClient); + +const TABLE_NAME = process.env.DYNAMODB_TABLE; + +exports.handler = async (event) => { + try { + const userId = event.requestContext.authorizer.principalId; + const queryParams = event.queryStringParameters || {}; + const limit = parseInt(queryParams.limit) || 50; + const lastKey = queryParams.lastKey ? JSON.parse(decodeURIComponent(queryParams.lastKey)) : undefined; + + // Query files for user + const params = { + TableName: TABLE_NAME, + KeyConditionExpression: 'userId = :userId', + ExpressionAttributeValues: { + ':userId': userId, + ':active': 'active' + }, + FilterExpression: '#status = :active', + ExpressionAttributeNames: { + '#status': 'status' + }, + Limit: limit, + ScanIndexForward: false + }; + + if (lastKey) { + params.ExclusiveStartKey = lastKey; + } + + const result = await docClient.send(new QueryCommand(params)); + + const files = result.Items.map(item => ({ + fileId: item.fileId, + fileName: item.fileName, + fileSize: item.fileSize, + contentType: item.contentType, + uploadDate: item.uploadDate, + shared: item.shared || false + })); + + return { + statusCode: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + files, + count: files.length, + lastKey: result.LastEvaluatedKey ? encodeURIComponent(JSON.stringify(result.LastEvaluatedKey)) : null + }) + }; + } catch (error) { + console.error('List error:', error); + return { + statusCode: 500, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Failed to list files', details: error.message }) + }; + } +}; diff --git a/backend/handlers/share.js b/backend/handlers/share.js new file mode 100644 index 0000000..bc54925 --- /dev/null +++ b/backend/handlers/share.js @@ -0,0 +1,94 @@ +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { DynamoDBDocumentClient, GetCommand, UpdateCommand } = require('@aws-sdk/lib-dynamodb'); +const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3'); +const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); + +const s3Client = new S3Client({}); +const dynamoClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(dynamoClient); + +const BUCKET_NAME = process.env.BUCKET_NAME; +const TABLE_NAME = process.env.DYNAMODB_TABLE; + +exports.handler = async (event) => { + try { + const userId = event.requestContext.authorizer.principalId; + const fileId = event.pathParameters.fileId; + const body = JSON.parse(event.body || '{}'); + const expiresIn = body.expiresIn || 86400; // Default 24 hours + + if (!fileId) { + return { + statusCode: 400, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Missing fileId parameter' }) + }; + } + + // Get file metadata + const result = await docClient.send(new GetCommand({ + TableName: TABLE_NAME, + Key: { userId, fileId } + })); + + if (!result.Item) { + return { + statusCode: 404, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'File not found' }) + }; + } + + const fileMetadata = result.Item; + + // Generate shareable presigned URL + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: fileMetadata.s3Key + }); + + const shareUrl = await getSignedUrl(s3Client, command, { expiresIn }); + + // Update file metadata to mark as shared + await docClient.send(new UpdateCommand({ + TableName: TABLE_NAME, + Key: { userId, fileId }, + UpdateExpression: 'SET shared = :shared, lastSharedAt = :lastSharedAt', + ExpressionAttributeValues: { + ':shared': true, + ':lastSharedAt': Date.now() + } + })); + + return { + statusCode: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'Share link generated successfully', + shareUrl, + fileName: fileMetadata.fileName, + expiresIn, + expiresAt: Date.now() + (expiresIn * 1000) + }) + }; + } catch (error) { + console.error('Share error:', error); + return { + statusCode: 500, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Failed to generate share link', details: error.message }) + }; + } +}; diff --git a/backend/handlers/upload.js b/backend/handlers/upload.js new file mode 100644 index 0000000..5c9c6a9 --- /dev/null +++ b/backend/handlers/upload.js @@ -0,0 +1,84 @@ +const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { DynamoDBDocumentClient, PutCommand } = require('@aws-sdk/lib-dynamodb'); +const { v4: uuidv4 } = require('uuid'); + +const s3Client = new S3Client({}); +const dynamoClient = new DynamoDBClient({}); +const docClient = DynamoDBDocumentClient.from(dynamoClient); + +const BUCKET_NAME = process.env.BUCKET_NAME; +const TABLE_NAME = process.env.DYNAMODB_TABLE; + +exports.handler = async (event) => { + try { + const userId = event.requestContext.authorizer.principalId; + const fileId = uuidv4(); + + // Parse file data from request + const body = JSON.parse(event.body); + const { fileName, fileContent, contentType, fileSize } = body; + + if (!fileName || !fileContent) { + return { + statusCode: 400, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Missing required fields: fileName, fileContent' }) + }; + } + + // Upload to S3 + const s3Key = `${userId}/${fileId}/${fileName}`; + const buffer = Buffer.from(fileContent, 'base64'); + + await s3Client.send(new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: s3Key, + Body: buffer, + ContentType: contentType || 'application/octet-stream' + })); + + // Store metadata in DynamoDB + const uploadDate = Date.now(); + await docClient.send(new PutCommand({ + TableName: TABLE_NAME, + Item: { + userId, + fileId, + fileName, + fileSize: fileSize || buffer.length, + contentType: contentType || 'application/octet-stream', + uploadDate, + s3Key, + status: 'active' + } + })); + + return { + statusCode: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + message: 'File uploaded successfully', + fileId, + fileName, + uploadDate + }) + }; + } catch (error) { + console.error('Upload error:', error); + return { + statusCode: 500, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ error: 'Failed to upload file', details: error.message }) + }; + } +}; diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 0000000..c8d4d6a --- /dev/null +++ b/backend/index.js @@ -0,0 +1,119 @@ +const express = require('express'); +const cors = require('cors'); +require('dotenv').config(); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true, limit: '50mb' })); + +// Import handlers +const uploadHandler = require('./handlers/upload'); +const downloadHandler = require('./handlers/download'); +const listHandler = require('./handlers/list'); +const deleteHandler = require('./handlers/delete'); +const shareHandler = require('./handlers/share'); + +// Middleware to simulate API Gateway event structure +const createLambdaEvent = (req) => { + return { + body: JSON.stringify(req.body), + pathParameters: req.params, + queryStringParameters: req.query, + headers: req.headers, + requestContext: { + authorizer: { + principalId: req.headers['x-user-id'] || 'demo-user' + } + } + }; +}; + +// Health check endpoint +app.get('/health', (req, res) => { + res.status(200).json({ status: 'healthy', service: 'cloudkeep-backend', timestamp: new Date().toISOString() }); +}); + +// API Routes +app.post('/upload', async (req, res) => { + try { + const event = createLambdaEvent(req); + const result = await uploadHandler.handler(event); + res.status(result.statusCode).json(JSON.parse(result.body)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/download/:fileId', async (req, res) => { + try { + const event = createLambdaEvent(req); + const result = await downloadHandler.handler(event); + res.status(result.statusCode).json(JSON.parse(result.body)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/files', async (req, res) => { + try { + const event = createLambdaEvent(req); + const result = await listHandler.handler(event); + res.status(result.statusCode).json(JSON.parse(result.body)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/files/:fileId', async (req, res) => { + try { + const event = createLambdaEvent(req); + const result = await deleteHandler.handler(event); + res.status(result.statusCode).json(JSON.parse(result.body)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/share/:fileId', async (req, res) => { + try { + const event = createLambdaEvent(req); + const result = await shareHandler.handler(event); + res.status(result.statusCode).json(JSON.parse(result.body)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Root endpoint +app.get('/', (req, res) => { + res.json({ + service: 'CloudKeep API', + version: '1.0.0', + endpoints: { + health: 'GET /health', + upload: 'POST /upload', + download: 'GET /download/:fileId', + list: 'GET /files', + delete: 'DELETE /files/:fileId', + share: 'POST /share/:fileId' + } + }); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error('Error:', err); + res.status(500).json({ error: 'Internal server error', message: err.message }); +}); + +// Start server +app.listen(PORT, () => { + console.log(`CloudKeep Backend running on port ${PORT}`); + console.log(`Health check: http://localhost:${PORT}/health`); +}); + +module.exports = app; diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..935a29b --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + testEnvironment: 'node', + coverageDirectory: 'coverage', + collectCoverageFrom: [ + 'handlers/**/*.js', + 'index.js', + '!**/node_modules/**' + ], + testMatch: [ + '**/__tests__/**/*.js', + '**/?(*.)+(spec|test).js' + ], + coveragePathIgnorePatterns: [ + '/node_modules/' + ], + testTimeout: 10000 +}; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d18391d --- /dev/null +++ b/backend/package.json @@ -0,0 +1,42 @@ +{ + "name": "cloudkeep-backend", + "version": "1.0.0", + "description": "CloudKeep backend - Serverless file storage API", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "jest --coverage", + "lint": "eslint .", + "deploy": "serverless deploy", + "deploy:dev": "serverless deploy --stage dev", + "deploy:staging": "serverless deploy --stage staging", + "deploy:prod": "serverless deploy --stage production", + "local": "serverless offline" + }, + "keywords": [ + "serverless", + "cloud-storage", + "aws", + "lambda" + ], + "author": "CloudKeep Team", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-dynamodb": "^3.478.0", + "@aws-sdk/client-s3": "^3.478.0", + "@aws-sdk/lib-dynamodb": "^3.478.0", + "@aws-sdk/s3-request-presigner": "^3.478.0", + "aws-jwt-verify": "^4.0.0", + "uuid": "^9.0.1", + "express": "^4.18.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "jest": "^29.7.0", + "eslint": "^8.55.0", + "serverless": "^3.38.0", + "serverless-offline": "^13.3.0", + "@types/jest": "^29.5.11" + } +} diff --git a/backend/serverless.yml b/backend/serverless.yml index e69de29..0ee69b4 100644 --- a/backend/serverless.yml +++ b/backend/serverless.yml @@ -0,0 +1,155 @@ +service: cloudkeep-backend + +frameworkVersion: '3' + +provider: + name: aws + runtime: nodejs18.x + stage: ${opt:stage, 'dev'} + region: ${opt:region, 'us-east-1'} + environment: + STAGE: ${self:provider.stage} + BUCKET_NAME: ${self:custom.bucketName} + DYNAMODB_TABLE: ${self:custom.tableName} + iam: + role: + statements: + - Effect: Allow + Action: + - s3:PutObject + - s3:GetObject + - s3:DeleteObject + - s3:ListBucket + Resource: + - arn:aws:s3:::${self:custom.bucketName}/* + - arn:aws:s3:::${self:custom.bucketName} + - Effect: Allow + Action: + - dynamodb:Query + - dynamodb:Scan + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:UpdateItem + - dynamodb:DeleteItem + Resource: + - arn:aws:dynamodb:${self:provider.region}:*:table/${self:custom.tableName} + +custom: + bucketName: cloudkeep-files-${self:provider.stage} + tableName: cloudkeep-metadata-${self:provider.stage} + +functions: + uploadFile: + handler: handlers/upload.handler + events: + - http: + path: /upload + method: post + cors: true + authorizer: + name: authorizer + resultTtlInSeconds: 300 + + downloadFile: + handler: handlers/download.handler + events: + - http: + path: /download/{fileId} + method: get + cors: true + authorizer: + name: authorizer + resultTtlInSeconds: 300 + + listFiles: + handler: handlers/list.handler + events: + - http: + path: /files + method: get + cors: true + authorizer: + name: authorizer + resultTtlInSeconds: 300 + + deleteFile: + handler: handlers/delete.handler + events: + - http: + path: /files/{fileId} + method: delete + cors: true + authorizer: + name: authorizer + resultTtlInSeconds: 300 + + shareFile: + handler: handlers/share.handler + events: + - http: + path: /share/{fileId} + method: post + cors: true + authorizer: + name: authorizer + resultTtlInSeconds: 300 + + authorizer: + handler: handlers/authorizer.handler + +resources: + Resources: + FilesBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: ${self:custom.bucketName} + CorsConfiguration: + CorsRules: + - AllowedOrigins: + - '*' + AllowedHeaders: + - '*' + AllowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + MaxAge: 3000 + LifecycleConfiguration: + Rules: + - Id: DeleteOldVersions + Status: Enabled + NoncurrentVersionExpirationInDays: 30 + VersioningConfiguration: + Status: Enabled + + MetadataTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: ${self:custom.tableName} + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: userId + AttributeType: S + - AttributeName: fileId + AttributeType: S + - AttributeName: uploadDate + AttributeType: N + KeySchema: + - AttributeName: userId + KeyType: HASH + - AttributeName: fileId + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: FilesByDate + KeySchema: + - AttributeName: userId + KeyType: HASH + - AttributeName: uploadDate + KeyType: RANGE + Projection: + ProjectionType: ALL + +plugins: + - serverless-offline diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 0000000..84eaece --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": [ + "react-app", + "react-app/jest" + ], + "rules": { + "no-console": "off" + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..27fbed9 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +/.pnp +.pnp.js + +# Testing +/coverage + +# Production +/build + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Misc +.eslintcache diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e69de29..a9f36bc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -0,0 +1,37 @@ +# Multi-stage build for CloudKeep Frontend + +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built assets from builder stage +COPY --from=builder /app/build /usr/share/nginx/html + +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:80/health || exit 1 + +# Expose port +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ab1bf92 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,63 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Handle React Router + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy (optional, for development) + location /api/ { + proxy_pass http://backend:3000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Error pages + error_page 404 /index.html; + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9cc620a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "cloudkeep-frontend", + "version": "1.0.0", + "description": "CloudKeep frontend - Modern cloud storage interface", + "private": true, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1", + "axios": "^1.6.2", + "react-dropzone": "^14.2.3", + "react-icons": "^4.12.0", + "aws-amplify": "^6.0.0", + "@aws-amplify/ui-react": "^6.0.0" + }, + "devDependencies": { + "@testing-library/react": "^14.1.2", + "@testing-library/jest-dom": "^6.1.5", + "@testing-library/user-event": "^14.5.1", + "react-scripts": "5.0.1", + "eslint": "^8.55.0", + "eslint-config-react-app": "^7.0.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --watchAll=false", + "test:watch": "react-scripts test", + "eject": "react-scripts eject", + "lint": "eslint src/" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "proxy": "http://localhost:3000" +} diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..9017818 --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,14 @@ + + + + + + + + CloudKeep - Secure Cloud Storage + + + +
+ + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..fc4387b --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,211 @@ +.App { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.App-header { + background: rgba(255, 255, 255, 0.95); + padding: 2rem; + text-align: center; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.App-header h1 { + color: #667eea; + font-size: 3rem; + margin-bottom: 0.5rem; + font-weight: 700; +} + +.App-header p { + color: #666; + font-size: 1.2rem; +} + +.App-main { + flex: 1; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 2rem; +} + +.upload-section { + text-align: center; + margin-bottom: 3rem; +} + +.upload-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: #667eea; + color: white; + padding: 1rem 2rem; + border-radius: 8px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.upload-button:hover { + background: #5568d3; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.5); +} + +.upload-button:active { + transform: translateY(0); +} + +.error-message { + background: #fee; + border: 1px solid #fcc; + color: #c33; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + text-align: center; +} + +.files-section { + background: rgba(255, 255, 255, 0.95); + padding: 2rem; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} + +.files-section h2 { + color: #333; + margin-bottom: 1.5rem; + font-size: 1.8rem; +} + +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: #999; +} + +.empty-state svg { + opacity: 0.3; + margin-bottom: 1rem; +} + +.empty-state p { + font-size: 1.1rem; +} + +.files-grid { + display: grid; + gap: 1rem; +} + +.file-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: white; + border: 1px solid #e0e0e0; + border-radius: 8px; + transition: all 0.2s ease; +} + +.file-card:hover { + border-color: #667eea; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15); + transform: translateX(4px); +} + +.file-icon { + color: #667eea; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: #f0f4ff; + border-radius: 8px; +} + +.file-info { + flex: 1; +} + +.file-info h3 { + color: #333; + font-size: 1rem; + margin-bottom: 0.25rem; + word-break: break-word; +} + +.file-meta { + color: #999; + font-size: 0.9rem; +} + +.file-actions { + display: flex; + gap: 0.5rem; +} + +.action-button { + background: none; + border: none; + color: #666; + cursor: pointer; + padding: 0.5rem; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.action-button:hover { + background: #f5f5f5; +} + +.action-button.delete { + color: #e74c3c; +} + +.action-button.delete:hover { + background: #fee; +} + +.App-footer { + background: rgba(255, 255, 255, 0.95); + padding: 1.5rem; + text-align: center; + color: #666; + margin-top: auto; +} + +@media (max-width: 768px) { + .App-header h1 { + font-size: 2rem; + } + + .App-main { + padding: 1rem; + } + + .files-section { + padding: 1rem; + } + + .file-card { + flex-direction: column; + align-items: flex-start; + } + + .file-actions { + width: 100%; + justify-content: flex-end; + } +} diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..205915e --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,177 @@ +import React, { useState, useEffect } from 'react'; +import './App.css'; +import { FiUpload, FiDownload, FiTrash2, FiShare2, FiFile } from 'react-icons/fi'; + +const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:3000'; + +function App() { + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + loadFiles(); + }, []); + + const loadFiles = async () => { + try { + const response = await fetch(`${API_URL}/files`, { + headers: { + 'x-user-id': 'demo-user' + } + }); + + if (response.ok) { + const data = await response.json(); + setFiles(data.files || []); + } + } catch (err) { + console.error('Error loading files:', err); + } + }; + + const handleFileUpload = async (event) => { + const file = event.target.files[0]; + if (!file) return; + + setUploading(true); + setError(null); + + try { + const reader = new FileReader(); + reader.onload = async (e) => { + const base64Content = e.target.result.split(',')[1]; + + const response = await fetch(`${API_URL}/upload`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': 'demo-user' + }, + body: JSON.stringify({ + fileName: file.name, + fileContent: base64Content, + contentType: file.type, + fileSize: file.size + }) + }); + + if (response.ok) { + await loadFiles(); + } else { + const data = await response.json(); + setError(data.error || 'Upload failed'); + } + setUploading(false); + }; + reader.readAsDataURL(file); + } catch (err) { + setError('Upload failed: ' + err.message); + setUploading(false); + } + }; + + const handleDelete = async (fileId, fileName) => { + if (!window.confirm(`Delete ${fileName}?`)) return; + + try { + const response = await fetch(`${API_URL}/files/${fileId}`, { + method: 'DELETE', + headers: { + 'x-user-id': 'demo-user' + } + }); + + if (response.ok) { + await loadFiles(); + } + } catch (err) { + console.error('Delete error:', err); + } + }; + + const formatFileSize = (bytes) => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; + }; + + const formatDate = (timestamp) => { + return new Date(timestamp).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+
+

CloudKeep

+

Secure Cloud Storage Solution

+
+ +
+
+ + +
+ + {error &&
{error}
} + +
+

Your Files ({files.length})

+ {files.length === 0 ? ( +
+ +

No files yet. Upload your first file to get started!

+
+ ) : ( +
+ {files.map((file) => ( +
+
+ +
+
+

{file.fileName}

+

+ {formatFileSize(file.fileSize)} • {formatDate(file.uploadDate)} +

+
+
+ +
+
+ ))} +
+ )} +
+
+ +
+

CloudKeep v1.0.0 - Secure, Simple, Scalable

+
+
+ ); +} + +export default App; diff --git a/frontend/src/App.test.js b/frontend/src/App.test.js new file mode 100644 index 0000000..3ccba2a --- /dev/null +++ b/frontend/src/App.test.js @@ -0,0 +1,66 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import App from './App'; + +// Mock fetch API +global.fetch = jest.fn(); + +describe('CloudKeep Frontend', () => { + beforeEach(() => { + // Reset fetch mock before each test + fetch.mockClear(); + // Mock successful but empty response + fetch.mockResolvedValue({ + ok: true, + json: async () => ({ files: [] }) + }); + }); + + test('renders CloudKeep header', async () => { + render(); + const headerElement = screen.getByText(/CloudKeep/i); + expect(headerElement).toBeInTheDocument(); + }); + + test('renders upload button', async () => { + render(); + const uploadButton = screen.getByText(/Upload File/i); + expect(uploadButton).toBeInTheDocument(); + }); + + test('renders your files section', async () => { + render(); + await waitFor(() => { + const filesSection = screen.getByText(/Your Files \(0\)/i); + expect(filesSection).toBeInTheDocument(); + }); + }); + + test('renders empty state when no files', async () => { + render(); + await waitFor(() => { + const emptyMessage = screen.getByText(/No files yet/i); + expect(emptyMessage).toBeInTheDocument(); + }); + }); + + test('renders footer', async () => { + render(); + const footerElement = screen.getByText(/CloudKeep v1.0.0/i); + expect(footerElement).toBeInTheDocument(); + }); + + test('calls API on mount', async () => { + render(); + await waitFor(() => { + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/files'), + expect.objectContaining({ + headers: expect.objectContaining({ + 'x-user-id': 'demo-user' + }) + }) + ); + }); + }); +}); diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..73927fc --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,21 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..2cb1087 --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/frontend/src/setupTests.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..ecc27ac --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,219 @@ +# CloudKeep Terraform Infrastructure + +This directory contains Terraform configurations for deploying CloudKeep infrastructure on AWS with best practices. + +## Architecture Components + +- **VPC**: Custom VPC with public and private subnets across 3 availability zones +- **NAT Gateways**: For outbound internet access from private subnets +- **AWS Cognito**: User authentication and authorization +- **API Gateway**: RESTful API management with Cognito authorizer +- **Lambda**: Serverless compute for backend functions +- **S3**: Encrypted file storage with versioning +- **DynamoDB**: Metadata storage with point-in-time recovery +- **ElastiCache**: Redis cluster for caching +- **Application Load Balancer**: HTTPS load balancing (optional) +- **Security Groups**: Network security controls +- **VPC Endpoints**: Private connections to AWS services + +## Directory Structure + +``` +terraform/ +├── main.tf # Main configuration +├── variables.tf # Variable definitions +├── outputs.tf # Output definitions +├── modules/ # Reusable modules +│ ├── vpc/ # VPC and networking +│ ├── cognito/ # User authentication +│ ├── security/ # Security groups +│ ├── s3/ # File storage +│ ├── dynamodb/ # Metadata database +│ ├── elasticache/ # Redis caching +│ ├── lambda/ # Lambda functions +│ ├── api-gateway/ # API management +│ └── alb/ # Load balancer +└── environments/ # Environment-specific configs + ├── dev/ + ├── staging/ + └── prod/ +``` + +## Prerequisites + +1. **Terraform**: >= 1.0 + ```bash + # Install Terraform + brew install terraform # macOS + # or download from https://www.terraform.io/downloads + ``` + +2. **AWS CLI**: Configured with appropriate credentials + ```bash + aws configure + ``` + +3. **S3 Backend Bucket**: Create manually before first run + ```bash + aws s3 mb s3://cloudkeep-terraform-state --region us-east-1 + aws s3api put-bucket-versioning \ + --bucket cloudkeep-terraform-state \ + --versioning-configuration Status=Enabled + ``` + +4. **DynamoDB State Lock Table**: + ```bash + aws dynamodb create-table \ + --table-name terraform-state-lock \ + --attribute-definitions AttributeName=LockID,AttributeType=S \ + --key-schema AttributeName=LockID,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST \ + --region us-east-1 + ``` + +## Usage + +### Initialize Terraform + +```bash +cd terraform +terraform init +``` + +### Plan Deployment + +```bash +# Development +terraform plan -var-file=environments/dev/terraform.tfvars + +# Staging +terraform plan -var-file=environments/staging/terraform.tfvars + +# Production +terraform plan -var-file=environments/prod/terraform.tfvars +``` + +### Apply Configuration + +```bash +# Development +terraform apply -var-file=environments/dev/terraform.tfvars + +# With auto-approve (use cautiously) +terraform apply -var-file=environments/dev/terraform.tfvars -auto-approve +``` + +### Destroy Infrastructure + +```bash +terraform destroy -var-file=environments/dev/terraform.tfvars +``` + +## Environment Configuration + +Each environment has its own `terraform.tfvars` file in `environments/{env}/`: + +- `dev`: Development environment (minimal resources) +- `staging`: Staging environment (production-like) +- `prod`: Production environment (high availability) + +## Outputs + +After successful deployment, Terraform outputs: + +- VPC ID +- Cognito User Pool details +- API Gateway URL +- S3 Bucket name +- DynamoDB Table name +- ElastiCache endpoint +- ALB DNS name + +View outputs: +```bash +terraform output +``` + +## Security Best Practices + +1. **Network Isolation**: Lambda functions in private subnets +2. **Encryption**: S3 and DynamoDB encrypted at rest +3. **HTTPS**: ALB with SSL/TLS certificates +4. **Least Privilege**: IAM roles with minimal permissions +5. **VPC Endpoints**: Private access to AWS services +6. **Security Groups**: Restrictive inbound/outbound rules +7. **Secrets Management**: Use AWS Secrets Manager (not in this config) + +## Cost Optimization + +- ElastiCache: t3.micro for dev, scale up for production +- NAT Gateways: 3 AZs for HA (reduce for dev) +- S3: Lifecycle policies for old versions +- DynamoDB: Pay-per-request billing + +## Troubleshooting + +### State Lock Error + +```bash +# Force unlock (use cautiously) +terraform force-unlock +``` + +### Resource Already Exists + +```bash +# Import existing resource +terraform import aws_s3_bucket.files +``` + +### Permission Denied + +Ensure AWS credentials have sufficient permissions: +- VPC, EC2, S3, DynamoDB, Lambda, API Gateway, Cognito, ElastiCache, IAM + +## Integration with Serverless + +The Terraform configuration creates the infrastructure, while Serverless Framework deploys the Lambda functions. Update `backend/serverless.yml` with Terraform outputs: + +```yaml +provider: + vpc: + securityGroupIds: + - ${terraform output lambda_sg_id} + subnetIds: + - ${terraform output private_subnet_ids} +``` + +## Maintenance + +### State Management + +- State is stored in S3 with encryption +- State locking via DynamoDB prevents concurrent modifications +- Enable versioning on state bucket + +### Updates + +```bash +# Update modules +terraform get -update + +# Upgrade provider versions +terraform init -upgrade +``` + +## Contributing + +When adding new resources: +1. Use modules for reusability +2. Add appropriate tags +3. Follow naming conventions +4. Document in this README +5. Test in dev before production + +## Additional Resources + +- [Terraform AWS Provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs) +- [AWS Well-Architected Framework](https://aws.amazon.com/architecture/well-architected/) +- [Terraform Best Practices](https://www.terraform.io/docs/cloud/guides/recommended-practices/index.html) \ No newline at end of file diff --git a/terraform/environments/dev/terraform.tfvars b/terraform/environments/dev/terraform.tfvars new file mode 100644 index 0000000..d640958 --- /dev/null +++ b/terraform/environments/dev/terraform.tfvars @@ -0,0 +1,18 @@ +environment = "dev" +aws_region = "us-east-1" + +# VPC Configuration +vpc_cidr = "10.0.0.0/16" +public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] +private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"] + +# Cognito Configuration +cognito_callback_urls = ["http://localhost:3000/callback", "https://dev.cloudkeep.example.com/callback"] +cognito_logout_urls = ["http://localhost:3000", "https://dev.cloudkeep.example.com"] + +# ElastiCache Configuration +elasticache_node_type = "cache.t3.micro" +elasticache_num_nodes = 1 + +# ACM Certificate ARN (leave empty to use HTTP only) +acm_certificate_arn = "" \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..f8a805e --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,122 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } + + backend "s3" { + bucket = "cloudkeep-terraform-state" + key = "terraform.tfstate" + region = "us-east-1" + encrypt = true + dynamodb_table = "terraform-state-lock" + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = "CloudKeep" + Environment = var.environment + ManagedBy = "Terraform" + } + } +} + +# VPC Module +module "vpc" { + source = "./modules/vpc" + + environment = var.environment + vpc_cidr = var.vpc_cidr + availability_zones = var.availability_zones + public_subnet_cidrs = var.public_subnet_cidrs + private_subnet_cidrs = var.private_subnet_cidrs +} + +# Security Groups Module +module "security" { + source = "./modules/security" + + environment = var.environment + vpc_id = module.vpc.vpc_id +} + +# Cognito Module +module "cognito" { + source = "./modules/cognito" + + environment = var.environment + user_pool_name = "${var.project_name}-users-${var.environment}" + callback_urls = var.cognito_callback_urls + logout_urls = var.cognito_logout_urls + allowed_oauth_scopes = ["email", "openid", "profile"] +} + +# S3 Module +module "s3" { + source = "./modules/s3" + + environment = var.environment + bucket_prefix = var.project_name +} + +# DynamoDB Module +module "dynamodb" { + source = "./modules/dynamodb" + + environment = var.environment + table_name = "${var.project_name}-metadata-${var.environment}" +} + +# ElastiCache Module +module "elasticache" { + source = "./modules/elasticache" + + environment = var.environment + cluster_id = "${var.project_name}-cache-${var.environment}" + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [module.security.elasticache_sg_id] + node_type = var.elasticache_node_type + num_cache_nodes = var.elasticache_num_nodes +} + +# Lambda Module +module "lambda" { + source = "./modules/lambda" + + environment = var.environment + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [module.security.lambda_sg_id] + s3_bucket_name = module.s3.files_bucket_name + dynamodb_table_name = module.dynamodb.table_name + user_pool_arn = module.cognito.user_pool_arn + elasticache_endpoint = module.elasticache.cache_endpoint +} + +# API Gateway Module +module "api_gateway" { + source = "./modules/api-gateway" + + environment = var.environment + lambda_functions = module.lambda.lambda_functions + user_pool_arn = module.cognito.user_pool_arn + api_name = "${var.project_name}-api-${var.environment}" +} + +# Application Load Balancer Module +module "alb" { + source = "./modules/alb" + + environment = var.environment + vpc_id = module.vpc.vpc_id + public_subnet_ids = module.vpc.public_subnet_ids + security_group_ids = [module.security.alb_sg_id] + certificate_arn = var.acm_certificate_arn +} \ No newline at end of file diff --git a/terraform/modules/alb/main.tf b/terraform/modules/alb/main.tf new file mode 100644 index 0000000..af07f1f --- /dev/null +++ b/terraform/modules/alb/main.tf @@ -0,0 +1,69 @@ +resource "aws_lb" "main" { + name = "cloudkeep-alb-${var.environment}" + internal = false + load_balancer_type = "application" + security_groups = var.security_group_ids + subnets = var.public_subnet_ids + + enable_deletion_protection = var.environment == "prod" ? true : false + + tags = { + Name = "cloudkeep-alb-${var.environment}" + } +} + +resource "aws_lb_target_group" "main" { + name = "cloudkeep-tg-${var.environment}" + port = 80 + protocol = "HTTP" + vpc_id = var.vpc_id + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 2 + timeout = 5 + interval = 30 + path = "/health" + matcher = "200" + } + + tags = { + Name = "cloudkeep-tg-${var.environment}" + } +} + +resource "aws_lb_listener" "http" { + load_balancer_arn = aws_lb.main.arn + port = "80" + protocol = "HTTP" + + default_action { + type = var.certificate_arn != "" ? "redirect" : "forward" + + dynamic "redirect" { + for_each = var.certificate_arn != "" ? [1] : [] + content { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + target_group_arn = var.certificate_arn == "" ? aws_lb_target_group.main.arn : null + } +} + +resource "aws_lb_listener" "https" { + count = var.certificate_arn != "" ? 1 : 0 + load_balancer_arn = aws_lb.main.arn + port = "443" + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" + certificate_arn = var.certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.main.arn + } +} \ No newline at end of file diff --git a/terraform/modules/alb/outputs.tf b/terraform/modules/alb/outputs.tf new file mode 100644 index 0000000..828acaf --- /dev/null +++ b/terraform/modules/alb/outputs.tf @@ -0,0 +1,15 @@ +output "alb_arn" { + value = aws_lb.main.arn +} + +output "alb_dns_name" { + value = aws_lb.main.dns_name +} + +output "alb_zone_id" { + value = aws_lb.main.zone_id +} + +output "target_group_arn" { + value = aws_lb_target_group.main.arn +} \ No newline at end of file diff --git a/terraform/modules/alb/variables.tf b/terraform/modules/alb/variables.tf new file mode 100644 index 0000000..2c22008 --- /dev/null +++ b/terraform/modules/alb/variables.tf @@ -0,0 +1,20 @@ +variable "environment" { + type = string +} + +variable "vpc_id" { + type = string +} + +variable "public_subnet_ids" { + type = list(string) +} + +variable "security_group_ids" { + type = list(string) +} + +variable "certificate_arn" { + type = string + default = "" +} \ No newline at end of file diff --git a/terraform/modules/api-gateway/main.tf b/terraform/modules/api-gateway/main.tf new file mode 100644 index 0000000..41726a5 --- /dev/null +++ b/terraform/modules/api-gateway/main.tf @@ -0,0 +1,20 @@ +# API Gateway will be created via Serverless Framework +# This module configures the authorizer and basic settings + +resource "aws_api_gateway_rest_api" "main" { + name = var.api_name + description = "CloudKeep API Gateway" + + endpoint_configuration { + types = ["REGIONAL"] + } +} + +resource "aws_api_gateway_authorizer" "cognito" { + name = "cognito-authorizer" + rest_api_id = aws_api_gateway_rest_api.main.id + type = "COGNITO_USER_POOLS" + provider_arns = [var.user_pool_arn] +} + +# API Gateway deployment will be handled by Serverless Framework \ No newline at end of file diff --git a/terraform/modules/api-gateway/outputs.tf b/terraform/modules/api-gateway/outputs.tf new file mode 100644 index 0000000..04853a9 --- /dev/null +++ b/terraform/modules/api-gateway/outputs.tf @@ -0,0 +1,11 @@ +output "api_id" { + value = aws_api_gateway_rest_api.main.id +} + +output "api_url" { + value = aws_api_gateway_rest_api.main.execution_arn +} + +output "authorizer_id" { + value = aws_api_gateway_authorizer.cognito.id +} \ No newline at end of file diff --git a/terraform/modules/api-gateway/variables.tf b/terraform/modules/api-gateway/variables.tf new file mode 100644 index 0000000..ce5c9ad --- /dev/null +++ b/terraform/modules/api-gateway/variables.tf @@ -0,0 +1,15 @@ +variable "environment" { + type = string +} + +variable "lambda_functions" { + type = any +} + +variable "user_pool_arn" { + type = string +} + +variable "api_name" { + type = string +} \ No newline at end of file diff --git a/terraform/modules/cognito/main.tf b/terraform/modules/cognito/main.tf new file mode 100644 index 0000000..277abba --- /dev/null +++ b/terraform/modules/cognito/main.tf @@ -0,0 +1,75 @@ +resource "aws_cognito_user_pool" "main" { + name = var.user_pool_name + + # Password policy + password_policy { + minimum_length = 12 + require_lowercase = true + require_uppercase = true + require_numbers = true + require_symbols = true + temporary_password_validity_days = 7 + } + + # Auto-verified attributes + auto_verified_attributes = ["email"] + + # User attributes + schema { + name = "email" + attribute_data_type = "String" + mutable = true + required = true + } + + # MFA configuration + mfa_configuration = "OPTIONAL" + + # Account recovery + account_recovery_setting { + recovery_mechanism { + name = "verified_email" + priority = 1 + } + } + + tags = { + Name = var.user_pool_name + } +} + +resource "aws_cognito_user_pool_client" "main" { + name = "${var.user_pool_name}-client" + user_pool_id = aws_cognito_user_pool.main.id + + generate_secret = false + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code", "implicit"] + allowed_oauth_scopes = var.allowed_oauth_scopes + callback_urls = var.callback_urls + logout_urls = var.logout_urls + supported_identity_providers = ["COGNITO"] + + explicit_auth_flows = [ + "ALLOW_USER_PASSWORD_AUTH", + "ALLOW_REFRESH_TOKEN_AUTH", + "ALLOW_USER_SRP_AUTH" + ] +} + +resource "aws_cognito_user_pool_domain" "main" { + domain = "${var.environment}-${replace(var.user_pool_name, "_", "-")}" + user_pool_id = aws_cognito_user_pool.main.id +} + +# Identity Pool for unauthenticated access (if needed) +resource "aws_cognito_identity_pool" "main" { + identity_pool_name = "${var.user_pool_name}-identity" + allow_unauthenticated_identities = false + + cognito_identity_providers { + client_id = aws_cognito_user_pool_client.main.id + provider_name = aws_cognito_user_pool.main.endpoint + server_side_token_check = true + } +} \ No newline at end of file diff --git a/terraform/modules/cognito/outputs.tf b/terraform/modules/cognito/outputs.tf new file mode 100644 index 0000000..d92b28d --- /dev/null +++ b/terraform/modules/cognito/outputs.tf @@ -0,0 +1,24 @@ +output "user_pool_id" { + description = "Cognito User Pool ID" + value = aws_cognito_user_pool.main.id +} + +output "user_pool_arn" { + description = "Cognito User Pool ARN" + value = aws_cognito_user_pool.main.arn +} + +output "user_pool_client_id" { + description = "Cognito User Pool Client ID" + value = aws_cognito_user_pool_client.main.id +} + +output "domain" { + description = "Cognito Domain" + value = aws_cognito_user_pool_domain.main.domain +} + +output "identity_pool_id" { + description = "Cognito Identity Pool ID" + value = aws_cognito_identity_pool.main.id +} \ No newline at end of file diff --git a/terraform/modules/cognito/variables.tf b/terraform/modules/cognito/variables.tf new file mode 100644 index 0000000..ea13698 --- /dev/null +++ b/terraform/modules/cognito/variables.tf @@ -0,0 +1,24 @@ +variable "environment" { + description = "Environment name" + type = string +} + +variable "user_pool_name" { + description = "Cognito User Pool name" + type = string +} + +variable "callback_urls" { + description = "Callback URLs" + type = list(string) +} + +variable "logout_urls" { + description = "Logout URLs" + type = list(string) +} + +variable "allowed_oauth_scopes" { + description = "Allowed OAuth scopes" + type = list(string) +} \ No newline at end of file diff --git a/terraform/modules/dynamodb/main.tf b/terraform/modules/dynamodb/main.tf new file mode 100644 index 0000000..65ffbe7 --- /dev/null +++ b/terraform/modules/dynamodb/main.tf @@ -0,0 +1,40 @@ +resource "aws_dynamodb_table" "metadata" { + name = var.table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "userId" + range_key = "fileId" + + attribute { + name = "userId" + type = "S" + } + + attribute { + name = "fileId" + type = "S" + } + + attribute { + name = "uploadDate" + type = "N" + } + + global_secondary_index { + name = "FilesByDate" + hash_key = "userId" + range_key = "uploadDate" + projection_type = "ALL" + } + + point_in_time_recovery { + enabled = true + } + + server_side_encryption { + enabled = true + } + + tags = { + Name = var.table_name + } +} \ No newline at end of file diff --git a/terraform/modules/dynamodb/outputs.tf b/terraform/modules/dynamodb/outputs.tf new file mode 100644 index 0000000..d6e039d --- /dev/null +++ b/terraform/modules/dynamodb/outputs.tf @@ -0,0 +1,7 @@ +output "table_name" { + value = aws_dynamodb_table.metadata.name +} + +output "table_arn" { + value = aws_dynamodb_table.metadata.arn +} \ No newline at end of file diff --git a/terraform/modules/dynamodb/variables.tf b/terraform/modules/dynamodb/variables.tf new file mode 100644 index 0000000..fb3aed6 --- /dev/null +++ b/terraform/modules/dynamodb/variables.tf @@ -0,0 +1,7 @@ +variable "environment" { + type = string +} + +variable "table_name" { + type = string +} \ No newline at end of file diff --git a/terraform/modules/elasticache/main.tf b/terraform/modules/elasticache/main.tf new file mode 100644 index 0000000..a7f4c13 --- /dev/null +++ b/terraform/modules/elasticache/main.tf @@ -0,0 +1,20 @@ +resource "aws_elasticache_subnet_group" "main" { + name = "${var.cluster_id}-subnet-group" + subnet_ids = var.subnet_ids +} + +resource "aws_elasticache_cluster" "main" { + cluster_id = var.cluster_id + engine = "redis" + node_type = var.node_type + num_cache_nodes = var.num_cache_nodes + parameter_group_name = "default.redis7" + engine_version = "7.0" + port = 6379 + subnet_group_name = aws_elasticache_subnet_group.main.name + security_group_ids = var.security_group_ids + + tags = { + Name = var.cluster_id + } +} \ No newline at end of file diff --git a/terraform/modules/elasticache/outputs.tf b/terraform/modules/elasticache/outputs.tf new file mode 100644 index 0000000..50416b6 --- /dev/null +++ b/terraform/modules/elasticache/outputs.tf @@ -0,0 +1,7 @@ +output "cache_endpoint" { + value = aws_elasticache_cluster.main.cache_nodes[0].address +} + +output "cache_port" { + value = aws_elasticache_cluster.main.port +} \ No newline at end of file diff --git a/terraform/modules/elasticache/variables.tf b/terraform/modules/elasticache/variables.tf new file mode 100644 index 0000000..45e3eed --- /dev/null +++ b/terraform/modules/elasticache/variables.tf @@ -0,0 +1,23 @@ +variable "environment" { + type = string +} + +variable "cluster_id" { + type = string +} + +variable "subnet_ids" { + type = list(string) +} + +variable "security_group_ids" { + type = list(string) +} + +variable "node_type" { + type = string +} + +variable "num_cache_nodes" { + type = number +} \ No newline at end of file diff --git a/terraform/modules/lambda/main.tf b/terraform/modules/lambda/main.tf new file mode 100644 index 0000000..efb01fb --- /dev/null +++ b/terraform/modules/lambda/main.tf @@ -0,0 +1,59 @@ +# IAM Role for Lambda +resource "aws_iam_role" "lambda" { + name_prefix = "cloudkeep-lambda-${var.environment}-" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "lambda_basic" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "lambda_vpc" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +# Lambda functions will be deployed via Serverless Framework +# This is a placeholder for IAM roles and policies + +resource "aws_iam_role_policy" "lambda_s3_dynamodb" { + role = aws_iam_role.lambda.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + Resource = "arn:aws:s3:::${var.s3_bucket_name}/*" + }, + { + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DeleteItem", + "dynamodb:Query", + "dynamodb:Scan" + ] + Resource = "arn:aws:dynamodb:*:*:table/${var.dynamodb_table_name}*" + } + ] + }) +} \ No newline at end of file diff --git a/terraform/modules/lambda/outputs.tf b/terraform/modules/lambda/outputs.tf new file mode 100644 index 0000000..720b329 --- /dev/null +++ b/terraform/modules/lambda/outputs.tf @@ -0,0 +1,9 @@ +output "lambda_role_arn" { + value = aws_iam_role.lambda.arn +} + +output "lambda_functions" { + value = { + role_arn = aws_iam_role.lambda.arn + } +} \ No newline at end of file diff --git a/terraform/modules/lambda/variables.tf b/terraform/modules/lambda/variables.tf new file mode 100644 index 0000000..da44081 --- /dev/null +++ b/terraform/modules/lambda/variables.tf @@ -0,0 +1,27 @@ +variable "environment" { + type = string +} + +variable "subnet_ids" { + type = list(string) +} + +variable "security_group_ids" { + type = list(string) +} + +variable "s3_bucket_name" { + type = string +} + +variable "dynamodb_table_name" { + type = string +} + +variable "user_pool_arn" { + type = string +} + +variable "elasticache_endpoint" { + type = string +} \ No newline at end of file diff --git a/terraform/modules/s3/main.tf b/terraform/modules/s3/main.tf new file mode 100644 index 0000000..3d76407 --- /dev/null +++ b/terraform/modules/s3/main.tf @@ -0,0 +1,58 @@ +resource "aws_s3_bucket" "files" { + bucket_prefix = "${var.bucket_prefix}-files-${var.environment}-" + + tags = { + Name = "${var.bucket_prefix}-files-${var.environment}" + } +} + +resource "aws_s3_bucket_versioning" "files" { + bucket = aws_s3_bucket.files.id + + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_encryption" { + bucket = aws_s3_bucket.files.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_public_access_block" "files" { + bucket = aws_s3_bucket.files.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_lifecycle_configuration" "files" { + bucket = aws_s3_bucket.files.id + + rule { + id = "delete-old-versions" + status = "Enabled" + + noncurrent_version_expiration { + noncurrent_days = 30 + } + } +} + +resource "aws_s3_bucket_cors_configuration" "files" { + bucket = aws_s3_bucket.files.id + + cors_rule { + allowed_headers = ["*"] + allowed_methods = ["GET", "PUT", "POST", "DELETE", "HEAD"] + allowed_origins = ["*"] + max_age_seconds = 3000 + } +} \ No newline at end of file diff --git a/terraform/modules/s3/outputs.tf b/terraform/modules/s3/outputs.tf new file mode 100644 index 0000000..3233e3c --- /dev/null +++ b/terraform/modules/s3/outputs.tf @@ -0,0 +1,7 @@ +output "files_bucket_name" { + value = aws_s3_bucket.files.id +} + +output "files_bucket_arn" { + value = aws_s3_bucket.files.arn +} \ No newline at end of file diff --git a/terraform/modules/s3/variables.tf b/terraform/modules/s3/variables.tf new file mode 100644 index 0000000..3f4da6d --- /dev/null +++ b/terraform/modules/s3/variables.tf @@ -0,0 +1,7 @@ +variable "environment" { + type = string +} + +variable "bucket_prefix" { + type = string +} \ No newline at end of file diff --git a/terraform/modules/security/main.tf b/terraform/modules/security/main.tf new file mode 100644 index 0000000..a81338f --- /dev/null +++ b/terraform/modules/security/main.tf @@ -0,0 +1,72 @@ +# ALB Security Group +resource "aws_security_group" "alb" { + name_prefix = "cloudkeep-alb-${var.environment}-" + description = "Security group for Application Load Balancer" + vpc_id = var.vpc_id + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "HTTPS from anywhere" + } + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + description = "HTTP from anywhere" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound" + } + + tags = { + Name = "cloudkeep-alb-sg-${var.environment}" + } +} + +# Lambda Security Group +resource "aws_security_group" "lambda" { + name_prefix = "cloudkeep-lambda-${var.environment}-" + description = "Security group for Lambda functions" + vpc_id = var.vpc_id + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + description = "Allow all outbound" + } + + tags = { + Name = "cloudkeep-lambda-sg-${var.environment}" + } +} + +# ElastiCache Security Group +resource "aws_security_group" "elasticache" { + name_prefix = "cloudkeep-elasticache-${var.environment}-" + description = "Security group for ElastiCache" + vpc_id = var.vpc_id + + ingress { + from_port = 6379 + to_port = 6379 + protocol = "tcp" + security_groups = [aws_security_group.lambda.id] + description = "Redis from Lambda" + } + + tags = { + Name = "cloudkeep-elasticache-sg-${var.environment}" + } +} \ No newline at end of file diff --git a/terraform/modules/security/outputs.tf b/terraform/modules/security/outputs.tf new file mode 100644 index 0000000..a76612f --- /dev/null +++ b/terraform/modules/security/outputs.tf @@ -0,0 +1,11 @@ +output "alb_sg_id" { + value = aws_security_group.alb.id +} + +output "lambda_sg_id" { + value = aws_security_group.lambda.id +} + +output "elasticache_sg_id" { + value = aws_security_group.elasticache.id +} \ No newline at end of file diff --git a/terraform/modules/security/variables.tf b/terraform/modules/security/variables.tf new file mode 100644 index 0000000..dec003d --- /dev/null +++ b/terraform/modules/security/variables.tf @@ -0,0 +1,7 @@ +variable "environment" { + type = string +} + +variable "vpc_id" { + type = string +} \ No newline at end of file diff --git a/terraform/modules/vpc/main.tf b/terraform/modules/vpc/main.tf new file mode 100644 index 0000000..f3f2efd --- /dev/null +++ b/terraform/modules/vpc/main.tf @@ -0,0 +1,125 @@ +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "cloudkeep-vpc-${var.environment}" + } +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "cloudkeep-igw-${var.environment}" + } +} + +resource "aws_subnet" "public" { + count = length(var.public_subnet_cidrs) + vpc_id = aws_vpc.main.id + cidr_block = var.public_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "cloudkeep-public-subnet-${var.environment}-${count.index + 1}" + Type = "Public" + } +} + +resource "aws_subnet" "private" { + count = length(var.private_subnet_cidrs) + vpc_id = aws_vpc.main.id + cidr_block = var.private_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = { + Name = "cloudkeep-private-subnet-${var.environment}-${count.index + 1}" + Type = "Private" + } +} + +resource "aws_eip" "nat" { + count = length(var.availability_zones) + domain = "vpc" + + tags = { + Name = "cloudkeep-nat-eip-${var.environment}-${count.index + 1}" + } + + depends_on = [aws_internet_gateway.main] +} + +resource "aws_nat_gateway" "main" { + count = length(var.availability_zones) + allocation_id = aws_eip.nat[count.index].id + subnet_id = aws_subnet.public[count.index].id + + tags = { + Name = "cloudkeep-nat-${var.environment}-${count.index + 1}" + } + + depends_on = [aws_internet_gateway.main] +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = { + Name = "cloudkeep-public-rt-${var.environment}" + } +} + +resource "aws_route_table" "private" { + count = length(var.availability_zones) + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.main[count.index].id + } + + tags = { + Name = "cloudkeep-private-rt-${var.environment}-${count.index + 1}" + } +} + +resource "aws_route_table_association" "public" { + count = length(var.public_subnet_cidrs) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table_association" "private" { + count = length(var.private_subnet_cidrs) + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private[count.index].id +} + +# VPC Endpoints for AWS Services +resource "aws_vpc_endpoint" "s3" { + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${data.aws_region.current.name}.s3" + + tags = { + Name = "cloudkeep-s3-endpoint-${var.environment}" + } +} + +resource "aws_vpc_endpoint" "dynamodb" { + vpc_id = aws_vpc.main.id + service_name = "com.amazonaws.${data.aws_region.current.name}.dynamodb" + + tags = { + Name = "cloudkeep-dynamodb-endpoint-${var.environment}" + } +} + +data "aws_region" "current" {} \ No newline at end of file diff --git a/terraform/modules/vpc/outputs.tf b/terraform/modules/vpc/outputs.tf new file mode 100644 index 0000000..e25373c --- /dev/null +++ b/terraform/modules/vpc/outputs.tf @@ -0,0 +1,19 @@ +output "vpc_id" { + description = "VPC ID" + value = aws_vpc.main.id +} + +output "public_subnet_ids" { + description = "Public subnet IDs" + value = aws_subnet.public[*].id +} + +output "private_subnet_ids" { + description = "Private subnet IDs" + value = aws_subnet.private[*].id +} + +output "nat_gateway_ids" { + description = "NAT Gateway IDs" + value = aws_nat_gateway.main[*].id +} \ No newline at end of file diff --git a/terraform/modules/vpc/variables.tf b/terraform/modules/vpc/variables.tf new file mode 100644 index 0000000..d67c82d --- /dev/null +++ b/terraform/modules/vpc/variables.tf @@ -0,0 +1,24 @@ +variable "environment" { + description = "Environment name" + type = string +} + +variable "vpc_cidr" { + description = "CIDR block for VPC" + type = string +} + +variable "availability_zones" { + description = "Availability zones" + type = list(string) +} + +variable "public_subnet_cidrs" { + description = "CIDR blocks for public subnets" + type = list(string) +} + +variable "private_subnet_cidrs" { + description = "CIDR blocks for private subnets" + type = list(string) +} \ No newline at end of file diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..827e564 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,49 @@ +output "vpc_id" { + description = "VPC ID" + value = module.vpc.vpc_id +} + +output "cognito_user_pool_id" { + description = "Cognito User Pool ID" + value = module.cognito.user_pool_id +} + +output "cognito_user_pool_client_id" { + description = "Cognito User Pool Client ID" + value = module.cognito.user_pool_client_id +} + +output "cognito_domain" { + description = "Cognito Domain" + value = module.cognito.domain +} + +output "api_gateway_url" { + description = "API Gateway URL" + value = module.api_gateway.api_url +} + +output "s3_bucket_name" { + description = "S3 Bucket Name for files" + value = module.s3.files_bucket_name +} + +output "dynamodb_table_name" { + description = "DynamoDB Table Name" + value = module.dynamodb.table_name +} + +output "elasticache_endpoint" { + description = "ElastiCache Endpoint" + value = module.elasticache.cache_endpoint +} + +output "alb_dns_name" { + description = "Application Load Balancer DNS Name" + value = module.alb.alb_dns_name +} + +output "alb_zone_id" { + description = "Application Load Balancer Zone ID" + value = module.alb.alb_zone_id +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..5acfba5 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,70 @@ +variable "aws_region" { + description = "AWS region for resources" + type = string + default = "us-east-1" +} + +variable "environment" { + description = "Environment name (dev, staging, prod)" + type = string +} + +variable "project_name" { + description = "Project name" + type = string + default = "cloudkeep" +} + +variable "vpc_cidr" { + description = "CIDR block for VPC" + type = string + default = "10.0.0.0/16" +} + +variable "availability_zones" { + description = "Availability zones" + type = list(string) + default = ["us-east-1a", "us-east-1b", "us-east-1c"] +} + +variable "public_subnet_cidrs" { + description = "CIDR blocks for public subnets" + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"] +} + +variable "private_subnet_cidrs" { + description = "CIDR blocks for private subnets" + type = list(string) + default = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"] +} + +variable "cognito_callback_urls" { + description = "Cognito callback URLs" + type = list(string) + default = ["http://localhost:3000/callback"] +} + +variable "cognito_logout_urls" { + description = "Cognito logout URLs" + type = list(string) + default = ["http://localhost:3000/logout"] +} + +variable "elasticache_node_type" { + description = "ElastiCache node type" + type = string + default = "cache.t3.micro" +} + +variable "elasticache_num_nodes" { + description = "Number of ElastiCache nodes" + type = number + default = 2 +} + +variable "acm_certificate_arn" { + description = "ARN of ACM certificate for HTTPS" + type = string + default = "" +} \ No newline at end of file