diff --git a/.circleci/config.yml b/.circleci/config.yml index dd5eecb..4ee5f6a 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,12 +27,11 @@ jobs: echo "${RES}" exit 1 fi - - run: - name: Install golangci-lint - command: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.6.0 - run: name: GolangCI Lint - command: golangci-lint run --timeout 300s + command: | + golangci-lint --version + golangci-lint run --verbose - save_cache: &save-cache paths: - /home/circleci/go/pkg/mod diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4dee6ef..5ff90f8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,7 +5,7 @@ Always reference these instructions first and fallback to search or bash command ## Working Effectively ### Bootstrap and Dependencies -- Install Go 1.25+: `go version` must show go1.25 or later +- Install Go 1.25.1+: `go version` must show go1.25.1 or later - Install Docker: Required for Vault development server - Install CLI tools for testing: ```bash @@ -13,7 +13,7 @@ Always reference these instructions first and fallback to search or bash command sudo apt-get update && sudo apt-get install -y curl jq # Check installations - go version # Must be 1.25+ + go version # Must be 1.25.1+ docker --version curl --version jq --version @@ -22,7 +22,7 @@ Always reference these instructions first and fallback to search or bash command ### Download Dependencies and Build - Download Go modules: `go mod download` -- takes 1-2 minutes. NEVER CANCEL. Set timeout to 180+ seconds. - Build binary: `go build -o sup3rs3cret cmd/sup3rS3cretMes5age/main.go` -- takes <1 second after dependencies downloaded. -- Install linter: `curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.64.8` -- takes 30-60 seconds. +- Install linter: `curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.7.2` -- takes 30-60 seconds. Current system has v2.7.2. ### Testing and Validation - Run tests: `make test` -- takes 2-3 minutes. NEVER CANCEL. Set timeout to 300+ seconds. @@ -51,17 +51,39 @@ The application will start on port 8080. Access at http://localhost:8080 docker stop vault-dev && docker rm vault-dev ``` -### Docker Build Issues -**IMPORTANT**: Docker builds currently fail in CI/containerized environments due to certificate verification issues with Go proxy: -``` -go: cloud.google.com/go@v0.112.1: Get "https://proxy.golang.org/...": tls: failed to verify certificate: x509: certificate signed by unknown authority +### Docker Build and Deployment +The project includes comprehensive Docker support: + +#### Local Development with Docker Compose +```bash +# Start full stack (Vault + App on port 8082) +make run +# or +docker compose -f deploy/docker-compose.yml up --build -d + +# View logs +make logs + +# Stop services +make stop + +# Clean up +make clean ``` -Do NOT attempt Docker builds (`make build`, `make image`, `docker compose up --build`) in sandboxed environments. These commands will fail after 15-30 seconds. Use local Go builds instead. +The default `docker-compose.yml` runs the app on port 8082 (HTTP) with Vault using token `supersecret`. + +#### Production Docker Image +```bash +# Build multi-platform image with attestations +make image +# Builds for linux/amd64 and linux/arm64 with SBOM and provenance -If you need to test Docker functionality, run individual commands: -- `make build` -- WILL FAIL in CI. Takes 15-30 seconds to fail. -- `make image` -- WILL FAIL in CI. Takes 15-30 seconds to fail. +# Alternative: Build local image only +docker compose -f deploy/docker-compose.yml build +``` + +**Note**: In some CI/containerized environments, Docker builds may encounter certificate verification issues with Go proxy. If this occurs, use local Go builds instead. ## Validation @@ -100,6 +122,16 @@ Always run these commands before committing: ## Common Tasks +### Key Application Features +- **Self-Destructing Messages**: Messages are automatically deleted after first read +- **Vault Backend**: Uses HashiCorp Vault's cubbyhole for secure temporary storage +- **TTL Support**: Configurable time-to-live (default 48h, max 168h/7 days) +- **File Upload**: Support for file uploads with base64 encoding (max 50MB) +- **One-Time Tokens**: Vault tokens with exactly 2 uses (1 to create, 1 to read) +- **Rate Limiting**: 10 requests per second to prevent abuse +- **TLS Support**: Auto TLS via Let's Encrypt or manual certificate configuration +- **No External Dependencies**: All JavaScript/fonts self-hosted for privacy + ### Configuration Environment Variables - `VAULT_ADDR`: Vault server address (e.g., `http://localhost:8200`) - `VAULT_TOKEN`: Vault authentication token (e.g., `supersecret` for dev) @@ -114,22 +146,45 @@ Always run these commands before committing: ### Repository Structure ``` . -├── cmd/sup3rS3cretMes5age/main.go # Application entry point +├── cmd/sup3rS3cretMes5age/ +│ └── main.go # Application entry point (23 lines) ├── internal/ # Core application logic -│ ├── config.go # Configuration handling -│ ├── handlers.go # HTTP request handlers -│ ├── server.go # Web server setup -│ └── vault.go # Vault integration +│ ├── config.go # Configuration handling (77 lines) +│ ├── handlers.go # HTTP request handlers (88 lines) +│ ├── handlers_test.go # Handler unit tests (87 lines) +│ ├── server.go # Web server setup (94 lines) +│ ├── vault.go # Vault integration (174 lines) +│ └── vault_test.go # Vault unit tests (66 lines) ├── web/static/ # Frontend assets (HTML, CSS, JS) +│ ├── index.html # Main page (5KB) +│ ├── getmsg.html # Message retrieval page (7.8KB) +│ ├── application.css # Styling (2.3KB) +│ ├── clipboard-2.0.11.min.js # Copy functionality (9KB) +│ ├── montserrat.css # Font definitions +│ ├── robots.txt # Search engine rules +│ ├── fonts/ # Self-hosted Montserrat font files +│ └── icons/ # Favicon and app icons ├── deploy/ # Docker and deployment configs -│ ├── Dockerfile # Container build (fails in CI) -│ ├── docker-compose.yml # Local development stack -│ └── charts/ # Helm charts for Kubernetes -├── Makefile # Build automation -├── go.mod # Go module definition -└── README.md # Project documentation -``` - +│ ├── Dockerfile # Multi-stage container build +│ ├── docker-compose.yml # Local development stack (Vault + App) +│ └── charts/supersecretmessage/ # Helm c(lint + test pipeline) +.codacy.yml # Code quality config +.dockerignore # Docker ignore patterns +.git/ # Git repository data +.github/ # GitHub configuration (copilot-instructions.md) +.gitignore # Git ignore patterns +CLI.md # Command-line usage guide (313 lines, Bash/Zsh/Fish examples) +CODEOWNERS # GitHub code owners +LICENSE # MIT license +Makefile # Build targets (test, image, build, run, logs, stop, clean) +Makefile.buildx # Advanced buildx targets (multi-platform, AWS ECR) +README.md # Main documentation (176 lines) +cmd/ # Application entry points +deploy/ # Deployment configurations (Docker, Helm) +go.mod # Go module file (go 1.25.1) +go.sum # Go dependency checksums +internal/ # Internal packages (609 lines total) +web/ # Web assets (static HTML, CSS, JS, fonts, icons) ### Frequently Used Commands Output #### Repository Root Files @@ -157,14 +212,14 @@ web/ # Web assets ```go module github.com/algolia/sup3rS3cretMes5age -go 1.25 +go 1.25.1 require ( - github.com/hashicorp/vault v1.16.3 - github.com/hashicorp/vault/api v1.14.0 + github.com/hashicorp/vault v1.21.0 + github.com/hashicorp/vault/api v1.22.0 github.com/labstack/echo/v4 v4.13.4 - github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.40.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.45.0 ) ``` @@ -195,8 +250,8 @@ o() { ### Troubleshooting **"go: ... tls: failed to verify certificate"** -- This occurs in Docker builds in CI environments -- Use local Go builds instead: `go build cmd/sup3rS3cretMes5age/main.go` +- This may occur in Docker builds in some CI environments +- Solution: Use local Go builds instead: `go build -o sup3rs3cret cmd/sup3rS3cretMes5age/main.go` **"jq: command not found"** ```bash @@ -216,3 +271,48 @@ brew install jq - Tests create their own Vault instances - Verbose logging is normal (200+ lines per test) - NEVER CANCEL tests - they clean up automatically + +**Port 8082 already in use** +```bash +# Find what's using the port +sudo lsof -i :8082 +# or +sudo netstat -tulpn | grep 8082 + +# Stop docker-compose if running +make stop +``` + +**Build fails with "cannot find package"** +```bash +# Clean Go module cache and re-download +go clean -modcache +go mod download +``` + +### Makefile Targets Reference +```bash +make test # Run all unit tests (takes 2-3 min) +make image # Build multi-platform Docker image with attestations +make build # Build Docker image via docker-compose +make run # Start docker-compose stack (Vault + App on :8082) +make run-local # Clean and start docker-compose +make logs # Tail docker-compose logs +make stop # Stop docker-compose services +make clean # Remove docker-compose containers +``` + +### CircleCI Pipeline +The project uses CircleCI with two jobs: +1. **lint**: Format checking (gofmt), golangci-lint v2.6.0 +2. **test**: Unit tests via `make test` + +Pipeline runs on Go 1.25 docker image (`cimg/go:1.25`). + +### Helm Deployment +Helm chart located in `deploy/charts/supersecretmessage/`: +- Chart version: 0.1.0 +- App version: 0.2.5 +- Includes: Deployment, Service, Ingress, HPA, ServiceAccount +- Configurable: Vault connection, TLS settings, resource limits +- See [deploy/charts/README.md](deploy/charts/README.md) for details diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..da0a3a5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,6 @@ +# Config format version +version: 2 + +run: + concurrency: 1 + timeout: 8m diff --git a/AWS_DEPLOYMENT.md b/AWS_DEPLOYMENT.md new file mode 100644 index 0000000..c2fd07b --- /dev/null +++ b/AWS_DEPLOYMENT.md @@ -0,0 +1,705 @@ +# AWS Deployment Guide + +This guide provides step-by-step instructions for deploying sup3rS3cretMes5age on AWS using various services. The application consists of two main components: +- The sup3rS3cretMes5age web application +- A HashiCorp Vault server for secure secret storage + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Option 1: ECS with Fargate (Recommended)](#option-1-ecs-with-fargate-recommended) +3. [Option 2: EKS (Kubernetes)](#option-2-eks-kubernetes) +4. [Option 3: EC2 with Docker](#option-3-ec2-with-docker) +5. [Security Considerations](#security-considerations) +6. [Cost Optimization](#cost-optimization) +7. [Troubleshooting](#troubleshooting) + +## Prerequisites + +Before starting, ensure you have: + +1. **AWS CLI** installed and configured with appropriate permissions +2. **Docker** installed for building and testing images locally +3. **Domain name** (recommended for HTTPS with Let's Encrypt) +4. **AWS Account** with the following IAM permissions: + - ECS full access + - ECR full access + - Application Load Balancer management + - VPC management + - IAM role creation + - Route 53 (if using custom domain) + +## Option 1: ECS with Fargate (Recommended) + +This option uses AWS ECS with Fargate for a serverless container deployment, which is cost-effective and easy to manage. + +### Step 1: Set up ECR Repository + +First, create a private ECR repository to store your Docker images: + +```bash +# Set your AWS region +export AWS_REGION=us-east-1 +export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + +# Create ECR repositories +aws ecr create-repository \ + --repository-name sup3rs3cretmes5age \ + --region $AWS_REGION + +aws ecr create-repository \ + --repository-name vault \ + --region $AWS_REGION + +# Get login token for ECR +aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com +``` + +### Step 2: Build and Push Docker Images + +Build and push the application image to ECR: + +```bash +# Clone and build the application +git clone https://github.com/algolia/sup3rS3cretMes5age.git +cd sup3rS3cretMes5age + +# Build the application image (with network=host to handle certificate issues) +docker build --network=host -f deploy/Dockerfile -t sup3rs3cretmes5age . + +# Tag for ECR +docker tag sup3rs3cretmes5age:latest $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/sup3rs3cretmes5age:latest + +# Push to ECR +docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/sup3rs3cretmes5age:latest + +# Pull and push Vault image to your ECR +docker pull hashicorp/vault:latest +docker tag hashicorp/vault:latest $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/vault:latest +docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/vault:latest +``` + +### Step 3: Create VPC and Security Groups + +Create a VPC with public and private subnets: + +```bash +# Create VPC +export VPC_ID=$(aws ec2 create-vpc \ + --cidr-block 10.0.0.0/16 \ + --query 'Vpc.VpcId' \ + --output text) + +aws ec2 create-tags \ + --resources $VPC_ID \ + --tags Key=Name,Value=sup3rs3cretmes5age-vpc + +# Create Internet Gateway +export IGW_ID=$(aws ec2 create-internet-gateway \ + --query 'InternetGateway.InternetGatewayId' \ + --output text) + +aws ec2 attach-internet-gateway \ + --vpc-id $VPC_ID \ + --internet-gateway-id $IGW_ID + +# Create public subnets in two AZs +export PUBLIC_SUBNET_1=$(aws ec2 create-subnet \ + --vpc-id $VPC_ID \ + --cidr-block 10.0.1.0/24 \ + --availability-zone ${AWS_REGION}a \ + --query 'Subnet.SubnetId' \ + --output text) + +export PUBLIC_SUBNET_2=$(aws ec2 create-subnet \ + --vpc-id $VPC_ID \ + --cidr-block 10.0.2.0/24 \ + --availability-zone ${AWS_REGION}b \ + --query 'Subnet.SubnetId' \ + --output text) + +# Create private subnets +export PRIVATE_SUBNET_1=$(aws ec2 create-subnet \ + --vpc-id $VPC_ID \ + --cidr-block 10.0.3.0/24 \ + --availability-zone ${AWS_REGION}a \ + --query 'Subnet.SubnetId' \ + --output text) + +export PRIVATE_SUBNET_2=$(aws ec2 create-subnet \ + --vpc-id $VPC_ID \ + --cidr-block 10.0.4.0/24 \ + --availability-zone ${AWS_REGION}b \ + --query 'Subnet.SubnetId' \ + --output text) + +# Create route table for public subnets +export PUBLIC_RT=$(aws ec2 create-route-table \ + --vpc-id $VPC_ID \ + --query 'RouteTable.RouteTableId' \ + --output text) + +aws ec2 create-route \ + --route-table-id $PUBLIC_RT \ + --destination-cidr-block 0.0.0.0/0 \ + --gateway-id $IGW_ID + +# Associate public subnets with route table +aws ec2 associate-route-table --subnet-id $PUBLIC_SUBNET_1 --route-table-id $PUBLIC_RT +aws ec2 associate-route-table --subnet-id $PUBLIC_SUBNET_2 --route-table-id $PUBLIC_RT + +# Enable auto-assign public IPs for public subnets +aws ec2 modify-subnet-attribute --subnet-id $PUBLIC_SUBNET_1 --map-public-ip-on-launch +aws ec2 modify-subnet-attribute --subnet-id $PUBLIC_SUBNET_2 --map-public-ip-on-launch +``` + +Create security groups: + +```bash +# Security group for Application Load Balancer +export ALB_SG=$(aws ec2 create-security-group \ + --group-name sup3rs3cretmes5age-alb-sg \ + --description "Security group for sup3rs3cretmes5age ALB" \ + --vpc-id $VPC_ID \ + --query 'GroupId' \ + --output text) + +# Allow HTTP and HTTPS traffic to ALB +aws ec2 authorize-security-group-ingress \ + --group-id $ALB_SG \ + --protocol tcp \ + --port 80 \ + --cidr 0.0.0.0/0 + +aws ec2 authorize-security-group-ingress \ + --group-id $ALB_SG \ + --protocol tcp \ + --port 443 \ + --cidr 0.0.0.0/0 + +# Security group for ECS tasks +export ECS_SG=$(aws ec2 create-security-group \ + --group-name sup3rs3cretmes5age-ecs-sg \ + --description "Security group for sup3rs3cretmes5age ECS tasks" \ + --vpc-id $VPC_ID \ + --query 'GroupId' \ + --output text) + +# Allow traffic from ALB to ECS tasks +aws ec2 authorize-security-group-ingress \ + --group-id $ECS_SG \ + --protocol tcp \ + --port 80 \ + --source-group $ALB_SG + +# Allow Vault communication between tasks +aws ec2 authorize-security-group-ingress \ + --group-id $ECS_SG \ + --protocol tcp \ + --port 8200 \ + --source-group $ECS_SG +``` + +### Step 4: Create Application Load Balancer + +```bash +# Create Application Load Balancer +export ALB_ARN=$(aws elbv2 create-load-balancer \ + --name sup3rs3cretmes5age-alb \ + --subnets $PUBLIC_SUBNET_1 $PUBLIC_SUBNET_2 \ + --security-groups $ALB_SG \ + --query 'LoadBalancers[0].LoadBalancerArn' \ + --output text) + +# Get ALB DNS name +export ALB_DNS=$(aws elbv2 describe-load-balancers \ + --load-balancer-arns $ALB_ARN \ + --query 'LoadBalancers[0].DNSName' \ + --output text) + +echo "ALB DNS Name: $ALB_DNS" + +# Create target group +export TARGET_GROUP_ARN=$(aws elbv2 create-target-group \ + --name sup3rs3cretmes5age-tg \ + --protocol HTTP \ + --port 80 \ + --vpc-id $VPC_ID \ + --target-type ip \ + --health-check-path / \ + --health-check-interval-seconds 30 \ + --health-check-timeout-seconds 5 \ + --healthy-threshold-count 2 \ + --unhealthy-threshold-count 3 \ + --query 'TargetGroups[0].TargetGroupArn' \ + --output text) + +# Create listener +aws elbv2 create-listener \ + --load-balancer-arn $ALB_ARN \ + --protocol HTTP \ + --port 80 \ + --default-actions Type=forward,TargetGroupArn=$TARGET_GROUP_ARN +``` + +### Step 5: Set up ECS Cluster and Task Definitions + +Create ECS cluster: + +```bash +# Create ECS cluster +aws ecs create-cluster --cluster-name sup3rs3cretmes5age-cluster +``` + +Create IAM roles for ECS: + +```bash +# Create ECS task execution role +cat > ecs-task-execution-role-trust-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] +} +EOF + +aws iam create-role \ + --role-name ecsTaskExecutionRole \ + --assume-role-policy-document file://ecs-task-execution-role-trust-policy.json + +aws iam attach-role-policy \ + --role-name ecsTaskExecutionRole \ + --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy + +# Get the role ARN +export TASK_EXECUTION_ROLE_ARN=$(aws iam get-role \ + --role-name ecsTaskExecutionRole \ + --query 'Role.Arn' \ + --output text) +``` + +Create task definition: + +```bash +cat > task-definition.json << EOF +{ + "family": "sup3rs3cretmes5age", + "networkMode": "awsvpc", + "requiresCompatibilities": ["FARGATE"], + "cpu": "512", + "memory": "1024", + "executionRoleArn": "$TASK_EXECUTION_ROLE_ARN", + "containerDefinitions": [ + { + "name": "vault", + "image": "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/vault:latest", + "essential": true, + "environment": [ + { + "name": "VAULT_DEV_ROOT_TOKEN_ID", + "value": "supersecret" + } + ], + "portMappings": [ + { + "containerPort": 8200, + "protocol": "tcp" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/sup3rs3cretmes5age", + "awslogs-region": "$AWS_REGION", + "awslogs-stream-prefix": "vault" + } + }, + "linuxParameters": { + "capabilities": { + "add": ["IPC_LOCK"] + } + } + }, + { + "name": "supersecret", + "image": "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/sup3rs3cretmes5age:latest", + "essential": true, + "dependsOn": [ + { + "containerName": "vault", + "condition": "START" + } + ], + "environment": [ + { + "name": "VAULT_ADDR", + "value": "http://localhost:8200" + }, + { + "name": "VAULT_TOKEN", + "value": "supersecret" + }, + { + "name": "SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS", + "value": ":80" + } + ], + "portMappings": [ + { + "containerPort": 80, + "protocol": "tcp" + } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/sup3rs3cretmes5age", + "awslogs-region": "$AWS_REGION", + "awslogs-stream-prefix": "supersecret" + } + } + } + ] +} +EOF + +# Create CloudWatch log group +aws logs create-log-group --log-group-name /ecs/sup3rs3cretmes5age + +# Register task definition +aws ecs register-task-definition --cli-input-json file://task-definition.json +``` + +### Step 6: Create ECS Service + +```bash +# Create ECS service +cat > service-definition.json << EOF +{ + "serviceName": "sup3rs3cretmes5age-service", + "cluster": "sup3rs3cretmes5age-cluster", + "taskDefinition": "sup3rs3cretmes5age", + "desiredCount": 1, + "launchType": "FARGATE", + "networkConfiguration": { + "awsvpcConfiguration": { + "subnets": ["$PRIVATE_SUBNET_1", "$PRIVATE_SUBNET_2"], + "securityGroups": ["$ECS_SG"], + "assignPublicIp": "DISABLED" + } + }, + "loadBalancers": [ + { + "targetGroupArn": "$TARGET_GROUP_ARN", + "containerName": "supersecret", + "containerPort": 80 + } + ] +} +EOF + +aws ecs create-service --cli-input-json file://service-definition.json +``` + +### Step 7: Configure Domain and HTTPS (Optional but Recommended) + +If you have a domain name, you can configure HTTPS: + +```bash +# Request SSL certificate (replace with your domain) +export DOMAIN_NAME="secrets.yourdomain.com" + +export CERT_ARN=$(aws acm request-certificate \ + --domain-name $DOMAIN_NAME \ + --validation-method DNS \ + --query 'CertificateArn' \ + --output text) + +echo "Certificate ARN: $CERT_ARN" +echo "Complete DNS validation in ACM console, then continue..." + +# After DNS validation is complete, create HTTPS listener +aws elbv2 create-listener \ + --load-balancer-arn $ALB_ARN \ + --protocol HTTPS \ + --port 443 \ + --certificates CertificateArn=$CERT_ARN \ + --default-actions Type=forward,TargetGroupArn=$TARGET_GROUP_ARN + +# Create Route 53 record (if using Route 53) +# You'll need to create this manually or use your DNS provider +``` + +## Option 2: EKS (Kubernetes) + +For teams already using Kubernetes, you can deploy using the provided Helm chart on Amazon EKS. + +### Prerequisites for EKS Deployment + +```bash +# Install required tools +# - kubectl +# - helm +# - eksctl (recommended for cluster creation) + +# Create EKS cluster +eksctl create cluster \ + --name sup3rs3cretmes5age \ + --region $AWS_REGION \ + --nodegroup-name standard-workers \ + --node-type t3.medium \ + --nodes 2 \ + --nodes-min 1 \ + --nodes-max 4 \ + --managed +``` + +### Deploy with Helm + +```bash +# Add Vault Helm repository +helm repo add hashicorp https://helm.releases.hashicorp.com +helm repo update + +# Install Vault +helm install vault hashicorp/vault \ + --set "server.dev.enabled=true" \ + --set "server.dev.devRootToken=supersecret" + +# Deploy sup3rS3cretMes5age using the provided Helm chart +cd sup3rS3cretMes5age/deploy/charts/supersecretmessage + +# Update values.yaml for your configuration +helm install sup3rs3cretmes5age . \ + --set image.repository=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/sup3rs3cretmes5age \ + --set image.tag=latest \ + --set vault.address=http://vault:8200 \ + --set vault.token=supersecret + +# Create ingress for external access +kubectl apply -f - << EOF +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: sup3rs3cretmes5age-ingress + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip +spec: + rules: + - http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: sup3rs3cretmes5age + port: + number: 80 +EOF +``` + +## Option 3: EC2 with Docker + +For a simpler setup, you can deploy on EC2 instances using Docker Compose. + +### Step 1: Launch EC2 Instance + +```bash +# Create key pair +aws ec2 create-key-pair \ + --key-name sup3rs3cretmes5age-key \ + --query 'KeyMaterial' \ + --output text > sup3rs3cretmes5age-key.pem + +chmod 400 sup3rs3cretmes5age-key.pem + +# Launch EC2 instance +export INSTANCE_ID=$(aws ec2 run-instances \ + --image-id ami-0c55b159cbfafe1d0 \ + --count 1 \ + --instance-type t3.small \ + --key-name sup3rs3cretmes5age-key \ + --security-groups default \ + --query 'Instances[0].InstanceId' \ + --output text) + +# Get public IP +export INSTANCE_IP=$(aws ec2 describe-instances \ + --instance-ids $INSTANCE_ID \ + --query 'Reservations[0].Instances[0].PublicIpAddress' \ + --output text) +``` + +### Step 2: Configure EC2 Instance + +```bash +# SSH to instance and set up Docker +ssh -i sup3rs3cretmes5age-key.pem ec2-user@$INSTANCE_IP + +# On the EC2 instance: +sudo yum update -y +sudo yum install -y docker +sudo service docker start +sudo usermod -a -G docker ec2-user + +# Install Docker Compose +sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +# Clone repository +git clone https://github.com/algolia/sup3rS3cretMes5age.git +cd sup3rS3cretMes5age + +# Start services +docker-compose -f deploy/docker-compose.yml up -d +``` + +## Security Considerations + +### 1. Use AWS Secrets Manager for Vault Token + +Instead of hardcoding the Vault token, use AWS Secrets Manager: + +```bash +# Create secret +aws secretsmanager create-secret \ + --name sup3rs3cretmes5age/vault-token \ + --description "Vault root token for sup3rs3cretmes5age" \ + --secret-string "your-secure-vault-token" + +# Update task definition to use secrets +# Add to containerDefinitions[].secrets: +{ + "name": "VAULT_TOKEN", + "valueFrom": "arn:aws:secretsmanager:region:account:secret:sup3rs3cretmes5age/vault-token" +} +``` + +### 2. Enable VPC Flow Logs + +```bash +aws ec2 create-flow-logs \ + --resource-type VPC \ + --resource-ids $VPC_ID \ + --traffic-type ALL \ + --log-destination-type cloud-watch-logs \ + --log-group-name VPCFlowLogs +``` + +### 3. Use HTTPS Only + +Always configure HTTPS and redirect HTTP traffic: + +```bash +# Modify task definition to enable HTTPS redirect +{ + "name": "SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED", + "value": "true" +} +``` + +### 4. Implement Network ACLs + +Create restrictive network ACLs for additional security: + +```bash +# Create network ACL +export NACL_ID=$(aws ec2 create-network-acl \ + --vpc-id $VPC_ID \ + --query 'NetworkAcl.NetworkAclId' \ + --output text) + +# Add rules as needed for your security requirements +``` + +## Cost Optimization + +### 1. Use Fargate Spot for Development + +For non-production environments, consider using Fargate Spot: + +```bash +# Update service to use Fargate Spot +aws ecs put-cluster-capacity-providers \ + --cluster sup3rs3cretmes5age-cluster \ + --capacity-providers FARGATE FARGATE_SPOT +``` + +### 2. Auto Scaling + +Configure auto scaling for production workloads: + +```bash +# Register scalable target +aws application-autoscaling register-scalable-target \ + --service-namespace ecs \ + --scalable-dimension ecs:service:DesiredCount \ + --resource-id service/sup3rs3cretmes5age-cluster/sup3rs3cretmes5age-service \ + --min-capacity 1 \ + --max-capacity 10 + +# Create scaling policy +aws application-autoscaling put-scaling-policy \ + --policy-name sup3rs3cretmes5age-scaling-policy \ + --service-namespace ecs \ + --scalable-dimension ecs:service:DesiredCount \ + --resource-id service/sup3rs3cretmes5age-cluster/sup3rs3cretmes5age-service \ + --policy-type TargetTrackingScaling \ + --target-tracking-scaling-policy-configuration file://scaling-policy.json +``` + +### 3. Use Reserved Instances for EC2 + +For long-running deployments, consider Reserved Instances to reduce costs. + +## Troubleshooting + +### Common Issues + +1. **Service won't start**: Check CloudWatch logs for container errors +2. **Can't access application**: Verify security group rules and target group health +3. **SSL certificate issues**: Ensure DNS validation is complete +4. **Vault connection errors**: Check network connectivity between containers + +### Debugging Commands + +```bash +# Check ECS service status +aws ecs describe-services \ + --cluster sup3rs3cretmes5age-cluster \ + --services sup3rs3cretmes5age-service + +# View logs +aws logs tail /ecs/sup3rs3cretmes5age --follow + +# Check target group health +aws elbv2 describe-target-health \ + --target-group-arn $TARGET_GROUP_ARN + +# Test internal connectivity +aws ecs execute-command \ + --cluster sup3rs3cretmes5age-cluster \ + --task \ + --container supersecret \ + --interactive \ + --command "/bin/sh" +``` + +### Support Resources + +- [AWS ECS Documentation](https://docs.aws.amazon.com/ecs/) +- [HashiCorp Vault Documentation](https://www.vaultproject.io/docs) +- [Application Load Balancer Documentation](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/) + +--- + +This guide provides multiple deployment options on AWS. Choose the option that best fits your team's expertise and requirements. For production deployments, we recommend Option 1 (ECS with Fargate) for its balance of simplicity, scalability, and cost-effectiveness. \ No newline at end of file diff --git a/Makefile b/Makefile index eb944d6..965f8e9 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,8 @@ DOCKER_OPS := -f deploy/Dockerfile TAG=$(shell git describe --tags --abbrev=0) VERSION=$(shell echo "$(TAG)" | sed -e 's/^v//') COMMIT=$(shell git rev-parse --short HEAD) +BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +VCS_REF=$(shell git rev-parse HEAD) ATTESTATIONS=--provenance=true --sbom=true PLATFORMS=--platform linux/amd64,linux/arm64 @@ -20,12 +22,14 @@ test: image: docker buildx build $(ATTESTATIONS) $(PLATFORMS) \ --build-arg VERSION=$(VERSION) \ + --build-arg BUILD_DATE="$(BUILD_DATE)" \ + --build-arg VCS_REF=$(VCS_REF) \ -t algolia/supersecretmessage:$(VERSION) \ -t algolia/supersecretmessage:$(COMMIT) \ -t algolia/supersecretmessage:latest \ $(DOCKER_OPS) . -build: +build: @docker compose $(COMPOSE_OPTS) build clean: @@ -35,7 +39,7 @@ run-local: clean @DOMAIN=$(DOMAIN) \ docker compose $(COMPOSE_OPTS) up --build -d -run: +run: @DOMAIN=$(DOMAIN) \ docker compose $(COMPOSE_OPTS) up --build -d diff --git a/README.md b/README.md index dd56719..25adedf 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,290 @@ # sup3rS3cretMes5age -A simple, secure self-destructing message service, using HashiCorp Vault product as a backend. +[![Go Version](https://img.shields.io/github/go-mod/go-version/algolia/sup3rS3cretMes5age.svg)](https://golang.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![CircleCI](https://img.shields.io/circleci/build/github/algolia/sup3rS3cretMes5age/master)](https://circleci.com/gh/algolia/sup3rS3cretMes5age) +[![Go Report Card](https://goreportcard.com/badge/github.com/algolia/sup3rS3cretMes5age)](https://goreportcard.com/report/github.com/algolia/sup3rS3cretMes5age) + +[![Awesome F/OSS](https://awsmfoss.com/content/images/2024/02/awsm-foss-badge.600x128.rounded.png)](https://awsmfoss.com/sup3rs3cretmes5age) + +A simple, secure, **self-destructing message service** that uses HashiCorp Vault as a backend for temporary secret storage. Share sensitive information with confidence knowing it will be automatically deleted after being read once. ![self-destruct](https://media.giphy.com/media/LBlyAAFJ71eMw/giphy.gif) +> 🔐 **Security First**: Messages are stored in Vault's cubbyhole backend with one-time tokens and automatic expiration. + Read more about the reasoning behind this project in the [relevant blog post](https://blog.algolia.com/secure-tool-for-one-time-self-destructing-messages/). -Now using [Let's Encrypt](https://letsencrypt.org/) for simple and free SSL certs! +## ✨ Features + +- **🔥 Self-Destructing Messages**: Messages are automatically deleted after first read +- **⏰ Configurable TTL**: Set custom expiration times (default 48h, max 7 days) +- **📎 File Upload Support**: Share files up to 50MB with base64 encoding +- **🔐 Vault-Backed Security**: Uses HashiCorp Vault's cubbyhole for tamper-proof storage +- **🎫 One-Time Tokens**: Vault tokens with exactly 2 uses (create + retrieve) +- **🚦 Rate Limiting**: Built-in protection (10 requests/second) +- **🌍 Multi-Language Support**: Interface available in 5 languages (EN, FR, DE, ES, IT) + - Automatic language detection from browser preferences + - URL-based language selection (`?lang=fr`) + - Dynamic switching without page reload +- **🔒 TLS/HTTPS Support**: + - Automatic TLS via [Let's Encrypt](https://letsencrypt.org/) + - Manual certificate configuration + - HTTP to HTTPS redirection +- **🌐 No External Dependencies**: All assets self-hosted for privacy +- **📦 Lightweight**: Only 8.9KB JavaScript (no jQuery) +- **🐳 Docker Ready**: Multi-platform images (amd64, arm64) with SBOM +- **☸️ Kubernetes Support**: Helm chart included +- **🖥️ CLI Integration**: Shell functions for Bash, Zsh, and Fish + +## 📋 Table of Contents + +- [Features](#-features) +- [Frontend Dependencies](#frontend-dependencies) +- [Quick Start](#-quick-start) +- [Deployment](#deployment) +- [Configuration](#configuration-options) +- [Multi-Language Support](#-multi-language-support) +- [Command Line Usage](#command-line-usage) +- [Helm Chart](#helm) +- [API Reference](#-api-reference) +- [Development](#-development) +- [Contributing](#contributing) +- [License](#license) ## Frontend Dependencies -The web interface is built with modern vanilla JavaScript and has minimal external dependencies: +The web interface is built with modern **vanilla JavaScript** (ES6 modules) and has minimal external dependencies: + +| Dependency | Size | Purpose | +|------------|------|----------| +| ClipboardJS v2.0.11 | 8.9KB | Copy to clipboard functionality | +| Montserrat Font | 46KB | Self-hosted typography | +| Custom CSS | 3.3KB | Application styling (minified) | +| Translation files | ~1KB each | i18n support (loaded on-demand) | -- **ClipboardJS v2.0.11** (8.9KB) - Copy to clipboard functionality -- **Montserrat Font** (46KB) - Self-hosted typography -- **Custom CSS** - Application styling +✅ **No external CDNs or tracking** - All dependencies are self-hosted for privacy and security. -**No external CDNs or tracking:** All dependencies are self-hosted for privacy and security. +📦 **Total JavaScript bundle size**: 8.9KB (previously 98KB with jQuery) -**Total JavaScript bundle size:** 8.9KB (previously 98KB with jQuery) +🌍 **Internationalization**: 5 languages supported (English, French, German, Spanish, Italian) +- Translations loaded asynchronously on-demand +- Browser language auto-detection +- Seamless language switching without page reload + +## 🚀 Quick Start + +Get up and running in less than 2 minutes: + +```bash +# Clone the repository +git clone https://github.com/algolia/sup3rS3cretMes5age.git +cd sup3rS3cretMes5age + +# Start with Docker Compose (recommended) +make run + +# Access the application +open http://localhost:8082 +``` + +The service will start with: +- **Application**: http://localhost:8082 +- **Vault dev server**: In-memory storage with token `supersecret` + +### Alternative: Local Build + +```bash +# Start Vault dev server +docker run -d --name vault-dev -p 8200:8200 \ + -e VAULT_DEV_ROOT_TOKEN_ID=supersecret \ + hashicorp/vault:latest + +# Build and run the application +go build -o sup3rs3cret cmd/sup3rS3cretMes5age/main.go +VAULT_ADDR=http://localhost:8200 \ +VAULT_TOKEN=supersecret \ +SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS=":8080" \ +./sup3rs3cret +``` ## Deployment -### Testing it locally +### Local Development + +#### Using Make (Recommended) -You can just run `docker-compose up -f deploy/docker-compose.yml --build` or run `make build`: it will build the Docker image and then run it alongside a standalone Vault server. +```bash +make run # Start services (Vault + App) +make logs # View logs +make stop # Stop services +make clean # Remove containers +``` + +#### Using Docker Compose Directly + +```bash +docker compose -f deploy/docker-compose.yml up --build -d +``` -By default, the `deploy/docker-compose.yml` is configured to run the webapp on port 8082 in cleartext HTTP (so you can access it on [http://localhost:8082](http://localhost:8082)). +By default, the application runs on **port 8082** in HTTP mode: [http://localhost:8082](http://localhost:8082) -Optionally, you can modify the `deploy/docker-compose.yml` and tweak the options (enable HTTPS, disable HTTP or enable redirection to HTTPS, etc.). See [Configuration options](#configuration-options). +💡 You can modify `deploy/docker-compose.yml` to enable HTTPS, HTTP redirection, or change ports. See [Configuration options](#configuration-options). ### Production Deployment -We recommend deploying the project via **Docker** and a **container orchestration tool**: +The image is available at: +- **Docker Hub**: `algolia/supersecretmessage:latest` +- **Platforms**: linux/amd64, linux/arm64 + +#### Docker Image -* Build the Docker image using the provided `Dockerfile` or run `make image` -* Host it in a Docker registry ([Docker Hub](https://hub.docker.com/), [AWS ECR](https://aws.amazon.com/ecr/), etc.) -* Deploy the image (alongside with a standalone Vault server) using a container orchestration tool ([Kubernetes](https://kubernetes.io/), [Docker Swarm](https://docs.docker.com/engine/swarm/), [AWS ECS](https://aws.amazon.com/ecs/), etc.) +Build multi-platform images with SBOM and provenance attestations: -You can read the [configuration examples](#configuration-examples) below. +```bash +# Build for multiple architectures +make image +# Builds: linux/amd64, linux/arm64 with SBOM and provenance +``` -### Security notice +#### AWS Deployment -Whatever deployment method you choose, **you should always run this behind SSL/TLS**, otherwise secrets will be sent _unencrypted_! +For detailed step-by-step instructions on deploying to AWS, see our comprehensive [AWS Deployment Guide](AWS_DEPLOYMENT.md). The guide covers: -Depending on your infrastructure/deployment, you can have **TLS termination** either _inside the container_ (see [Configuration examples - TLS](#tls)), or _before_ e.g. at a load balancer/reverse proxy in front of the service. -It is interesting to have TLS termination before the container so you don't have to manage the certificate/key there, but **make sure the network** between your TLS termination point and your container **is secure**. +- **ECS with Fargate** (recommended) - Serverless containers with Application Load Balancer +- **EKS (Kubernetes)** - Using the provided Helm chart on Amazon EKS +- **EC2 with Docker** - Simple deployment using Docker Compose + +```bash +# Build for multiple architectures +make image +# Builds: linux/amd64, linux/arm64 with SBOM and provenance +``` + +#### Deployment Platforms + +Deploy using your preferred orchestration tool: + +| Platform | Documentation | +|----------|---------------| +| Kubernetes | See [Helm Chart](#helm) below | +| Docker Swarm | Use the provided `docker-compose.yml` | +| AWS ECS | Use the Docker image with ECS task definition | + +**Important**: Deploy alongside a production Vault server. Configure via environment variables: +- `VAULT_ADDR`: Your Vault server URL +- `VAULT_TOKEN`: Vault authentication token + +See [configuration examples](#configuration-examples) below. + +### 🔒 Security Notice + +> ⚠️ **Critical**: Always run this service behind SSL/TLS in production. Secrets sent over HTTP are vulnerable to interception! + +#### TLS Termination Options + +**Option 1: Inside the Container** (Recommended for simplicity) +- Configure via environment variables +- Automatic Let's Encrypt certificates +- See [Configuration examples - TLS](#tls) + +**Option 2: External Load Balancer/Reverse Proxy** +- Simpler certificate management +- Offload TLS processing +- **Ensure secure network** between proxy and container +- Examples: AWS ALB, Nginx, Traefik, Cloudflare + +#### Security Best Practices + +- ✅ Use HTTPS/TLS in production +- ✅ Use a production Vault server (not dev mode) +- ✅ Rotate Vault tokens regularly +- ✅ Enable rate limiting (built-in: 10 req/s) +- ✅ Monitor Vault audit logs +- ✅ Use strong Vault policies +- ✅ Keep dependencies updated ## Helm -For full documentation for this chart, please see the [README](https://github.com/algolia/sup3rS3cretMes5age/blob/master/deployments/charts/README.md) +Deploy to Kubernetes using the included Helm chart: + +```bash +helm install supersecret ./deploy/charts/supersecretmessage \ + --set config.vault.address=http://vault.default.svc.cluster.local:8200 \ + --set config.vault.token_secret.name=vault-token +``` + +**Chart Details**: +- Chart Version: 0.1.0 +- App Version: 0.2.5 +- Includes: Deployment, Service, Ingress, HPA, ServiceAccount + +For full documentation, see the [Helm Chart README](deploy/charts/README.md) + +## 📡 API Reference + +### Create Secret Message + +**Endpoint**: `POST /secret` + +**Content-Type**: `multipart/form-data` + +**Parameters**: +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `msg` | string | Yes | The secret message content | +| `ttl` | string | No | Time-to-live (default: 48h, max: 168h) | +| `file` | file | No | File to upload (max 50MB) | + +**Response**: +```json +{ + "token": "s.abc123def456", + "filetoken": "s.xyz789uvw012", // If file uploaded + "filename": "secret.pdf" // If file uploaded +} +``` + +**Example**: +```bash +# Text message +curl -X POST -F 'msg=This is a secret' http://localhost:8082/secret + +# With custom TTL +curl -X POST -F 'msg=Short-lived secret' -F 'ttl=1h' http://localhost:8082/secret + +# With file +curl -X POST -F 'msg=Check this file' -F 'file=@secret.pdf' http://localhost:8082/secret +``` + +### Retrieve Secret Message + +**Endpoint**: `GET /secret?token=` + +**Parameters**: +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `token` | string | Yes | The token from POST response | + +**Response**: +```json +{ + "msg": "This is a secret" +} +``` + +**Example**: +```bash +curl "http://localhost:8082/secret?token=s.abc123def456" +``` + +⚠️ **Note**: After retrieval, the message and token are permanently deleted. Second attempts will fail. + +### Health Check + +**Endpoint**: `GET /health` + +**Response**: `OK` (HTTP 200) ## Command Line Usage @@ -158,18 +393,167 @@ SUPERSECRETMESSAGE_TLS_CERT_FILEPATH=/mnt/ssl/cert_secrets.example.com.pem SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH=/mnt/ssl/key_secrets.example.com.pem ``` -## Screenshot +## 🌍 Multi-Language Support + +The application supports 5 languages with automatic detection and seamless switching: + +### Supported Languages + +| Language | Code | Translation Coverage | +|----------|------|---------------------| +| 🇬🇧 English | `en` | Complete (23 keys) | +| 🇫🇷 French | `fr` | Complete (23 keys) | +| 🇩🇪 German | `de` | Complete (23 keys) | +| 🇪🇸 Spanish | `es` | Complete (23 keys) | +| 🇮🇹 Italian | `it` | Complete (23 keys) | + +### Usage + +**Automatic Detection**: The application automatically detects the user's preferred language from: +1. URL parameter: `https://example.com/?lang=fr` +2. Browser language settings +3. Defaults to English if no match + +**Manual Selection**: Users can switch languages using the selector in the top-right corner. + +**Features**: +- ✅ No page reload required +- ✅ Language preference persisted in URL +- ✅ Dynamic updates of all UI elements +- ✅ Translates meta tags for SEO +- ✅ Updates HTML `lang` attribute for accessibility +- ✅ Translations loaded asynchronously (only active language) + +### Technical Implementation + +- **ES6 Modules**: Modern JavaScript with proper import/export +- **CSP-Compliant**: All event handlers use `addEventListener()` +- **i18n System**: Centralized in `utils.js` with `data-i18n` attributes +- **Translation Files**: JSON format in `/static/locales/` +- **Size Impact**: ~1KB per language file (loaded on-demand) + +## 📸 Screenshots + +### Message Creation Interface +![supersecretmsg](https://github.com/user-attachments/assets/95fa8704-118b-4a42-b4a0-4f59b82ce1d1) + +*Clean, intuitive interface for creating self-destructing messages with optional file uploads and custom TTL.* + +### Message Retrieval Interface +![supersecretmsg](https://github.com/user-attachments/assets/74a6ff23-b459-4ead-8c6d-13bdf15a3a65) + +*Simple, secure interface for viewing self-destructing messages that are permanently deleted upon retrieval.* + +## 🛠️ Development + +### Prerequisites -![supersecretmsg](https://user-images.githubusercontent.com/357094/29357449-e9268adc-8277-11e7-8fef-b1eabfe62444.png) +- Go 1.25.1 or later +- Docker (for Vault dev server) +- Make (optional, for convenience) + +### Setup + +```bash +# Clone the repository +git clone https://github.com/algolia/sup3rS3cretMes5age.git +cd sup3rS3cretMes5age + +# Download dependencies +go mod download + +# Build the binary +go build -o sup3rs3cret cmd/sup3rS3cretMes5age/main.go +``` + +### Running Tests + +```bash +# Run all tests +make test + +# Or directly with go +go test ./... -v +``` + +### Code Quality + +```bash +# Format code +gofmt -s -w . + +# Lint +golangci-lint run --timeout 300s + +# Static analysis +go vet ./... +``` + +### Project Structure + +``` +. +├── cmd/sup3rS3cretMes5age/ # Application entry point +│ └── main.go # (67 lines) +├── internal/ # Core business logic +│ ├── config.go # Configuration handling (83 lines) +│ ├── handlers.go # HTTP request handlers (201 lines) +│ ├── server.go # Web server setup (370 lines) +│ └── vault.go # Vault integration (192 lines) +├── web/static/ # Frontend assets +│ ├── index.html # Message creation page +│ ├── getmsg.html # Message retrieval page +│ ├── index.js # Main page logic (ES6 modules) +│ ├── getmsg.js # Retrieval page logic (ES6 modules) +│ ├── utils.js # i18n utilities & helpers (130 lines) +│ ├── application.css # Styling +│ ├── clipboard-2.0.11.min.js +│ └── locales/ # Translation files +│ ├── en.json # English (23 keys) +│ ├── fr.json # French (23 keys) +│ ├── de.json # German (23 keys) +│ ├── es.json # Spanish (23 keys) +│ └── it.json # Italian (23 keys) +├── deploy/ # Deployment configs +│ ├── Dockerfile # Multi-stage build with security hardening +│ ├── docker-compose.yml # Local dev stack with resource limits +│ └── charts/ # Helm chart for Kubernetes +└── Makefile # Build automation & minification +``` + +**Total Code**: 1,043 lines of Go across 4 core files (excluding tests) ## Contributing -Pull requests are very welcome! -Please consider that they will be reviewed by our team at Algolia. +Contributions are welcome! 🎉 + +### How to Contribute + +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 + +### Guidelines + +- Write tests for new features +- Follow existing code style +- Update documentation as needed +- Ensure all tests pass (`make test`) +- Run linters (`golangci-lint run`) + +All pull requests will be reviewed by the Algolia team. + +## License +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -## Thanks +## 🙏 Acknowledgments -This project is heavaily depandent on the amazing work of the [Echo Go Web Framework](https://github.com/labstack/echo) and Hashicorp Vault. +This project is built on the shoulders of giants: -[![This project is certified Awesome F/OSS](https://awsmfoss.com/content/images/2024/02/awsm-foss-badge.600x128.rounded.png)](https://awsmfoss.com/sup3rs3cretmes5age) +- **[HashiCorp Vault](https://www.vaultproject.io/)** - Secure secret storage backend +- **[Echo](https://echo.labstack.com/)** - High performance Go web framework +- **[Let's Encrypt](https://letsencrypt.org/)** - Free SSL/TLS certificates +- **[ClipboardJS](https://clipboardjs.com/)** - Modern clipboard functionality diff --git a/cmd/sup3rS3cretMes5age/main.go b/cmd/sup3rS3cretMes5age/main.go index 4af00e8..1758c9a 100644 --- a/cmd/sup3rS3cretMes5age/main.go +++ b/cmd/sup3rS3cretMes5age/main.go @@ -1,13 +1,20 @@ +// Package main provides the entry point for the sup3rS3cretMes5age application, +// a secure self-destructing message service using HashiCorp Vault as a backend. package main import ( + "context" "flag" "fmt" "os" + "os/signal" + "syscall" + "time" "github.com/algolia/sup3rS3cretMes5age/internal" ) +// version holds the application version string, injected at build time via ldflags. var version = "" func main() { @@ -17,7 +24,43 @@ func main() { fmt.Println(version) os.Exit(0) } - + // Load configuration conf := internal.LoadConfig() - internal.Serve(conf) + + // Create server with handlers + handlers := internal.NewSecretHandlers(internal.NewVault("", conf.VaultPrefix, "")) + server := internal.NewServer(conf, handlers) + + // Setup graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + + // Listen for interrupt signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Start server in goroutine + go func() { + if err := server.Start(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Server error: %v\n", err) + os.Exit(1) + } + }() + + // Wait for interrupt signal + <-sigChan + fmt.Println("\nShutting down gracefully...") + + // Cancel context to signal server to stop + cancel() + + // Give server 10 seconds to finish existing requests + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + fmt.Fprintf(os.Stderr, "Shutdown error: %v\n", err) + os.Exit(1) + } + + fmt.Println("Server stopped successfully") } diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 403d6b9..b180f1b 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -3,36 +3,102 @@ FROM golang:1.25 AS builder WORKDIR /go/src/github.com/algolia/sup3rS3cretMes5age ARG VERSION +ARG BUILD_DATE +ARG VCS_REF + +# Add security-related labels +LABEL org.opencontainers.image.title="sup3rS3cretMes5age" \ + org.opencontainers.image.description="Secure self-destructing message service" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.created="${BUILD_DATE}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.vendor="Algolia" \ + org.opencontainers.image.licenses="MIT" COPY . . + +# build process RUN go mod download -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -a -ldflags "-X main.version=${VERSION} -s -w -extldflags '-static'" -o /tmp/sup3rS3cretMes5age cmd/sup3rS3cretMes5age/main.go +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -a \ + -ldflags "-X main.version=${VERSION} -s -w -extldflags '-static'" \ + -o /tmp/sup3rS3cretMes5age \ + cmd/sup3rS3cretMes5age/main.go +# Web assets minification stage +FROM node:latest AS web-builder +WORKDIR /app +COPY web/ ./ +# Minify JS and CSS files +RUN npm install -g @node-minify/cli \ + @node-minify/terser \ + @node-minify/lightningcss \ + @node-minify/html-minifier \ + @node-minify/jsonminify && \ + cd static && \ + for fi in utils.js index.js getmsg.js; \ + do \ + node-minify --compressor terser --input "$fi" --output "min.$fi" && mv "min.$fi" "$fi"; \ + done && \ + for fi in *.html; \ + do \ + node-minify --compressor html-minifier --input "$fi" --output "min.$fi" && mv "min.$fi" "$fi"; \ + done && \ + node-minify --compressor lightningcss --input application.css --output min.application.css && mv min.application.css application.css && \ + ls -l && \ + cd locales && \ + for fi in *.json; \ + do \ + node-minify --compressor jsonminify --input "$fi" --output "min.$fi" && mv "min.$fi" "$fi"; \ + done && \ + ls -l +# Multi-stage build with security hardening FROM alpine:latest +# Install only necessary certificates and packages +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + curl \ + && rm -rf /var/cache/apk/* + +# Create non-root user with restricted permissions +RUN addgroup -S -g 1001 supersecret \ + && adduser -S -u 1001 -G supersecret supersecret + +# Set up working directory with restricted permissions WORKDIR /opt/supersecret -COPY --from=builder /tmp/sup3rS3cretMes5age . -COPY web/static/ /opt/supersecret/static/ +# Copy binary and static assets +COPY --from=builder --chown=supersecret:supersecret /tmp/sup3rS3cretMes5age ./sup3rS3cretMes5age +COPY --from=web-builder --chown=supersecret:supersecret /app/static ./static +# Set proper file permissions +RUN chmod 755 ./sup3rS3cretMes5age \ + && chmod 644 ./static/* \ + && find ./static -type d -exec chmod 755 {} \; + +# Define environment variables ENV \ - VAULT_ADDR \ - VAULT_TOKEN \ - SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS \ - SUPERSECRETMESSAGE_HTTPS_BINDING_ADDRESS \ - SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED \ - SUPERSECRETMESSAGE_TLS_AUTO_DOMAIN \ - SUPERSECRETMESSAGE_TLS_CERT_FILEPATH \ - SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH \ - SUPERSECRETMESSAGE_VAULT_PREFIX - -RUN apk add --no-cache ca-certificates && \ - rm -rf /var/cache/apk/* && \ - addgroup -S supersecret && \ - adduser -S supersecret -G supersecret && \ - chown -R supersecret:supersecret /opt/supersecret - + VAULT_ADDR="" \ + VAULT_TOKEN="" \ + SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS=":8082" \ + SUPERSECRETMESSAGE_HTTPS_BINDING_ADDRESS="" \ + SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED="false" \ + SUPERSECRETMESSAGE_TLS_AUTO_DOMAIN="" \ + SUPERSECRETMESSAGE_TLS_CERT_FILEPATH="" \ + SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH="" \ + SUPERSECRETMESSAGE_VAULT_PREFIX="cubbyhole/" \ + GODEBUG=x509ignoreCN=0 \ + GOGC=200 \ + GOMAXPROCS=1 + USER supersecret -CMD ["./sup3rS3cretMes5age" ] +# Expose only necessary ports +EXPOSE 8082 + +ENTRYPOINT ["/opt/supersecret/sup3rS3cretMes5age"] +CMD [] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 40c5004..8e4b82d 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.2' +version: '3.8' services: vault: @@ -6,12 +6,23 @@ services: container_name: vault environment: VAULT_DEV_ROOT_TOKEN_ID: supersecret + VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" cap_add: - IPC_LOCK security_opt: - no-new-privileges:true - expose: - - 8200 + - seccomp=unconfined + tmpfs: + - /tmp + - /dev/shm + networks: + - secure-network + # Security-focused health check + healthcheck: + test: ["CMD", "vault", "status", "-address=http://localhost:8200"] + interval: 30s + timeout: 10s + retries: 3 supersecret: build: @@ -23,10 +34,48 @@ services: VAULT_ADDR: http://vault:8200 VAULT_TOKEN: supersecret SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS: ":8082" + SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED: "false" + # Security-focused environment variables + GODEBUG: "x509ignoreCN=0" + GOGC: "200" + GOMAXPROCS: "1" security_opt: - no-new-privileges:true + - apparmor=unconfined + - seccomp=unconfined read_only: true + # Mount only necessary directories as read-only + tmpfs: + - /tmp ports: - "8082:8082" depends_on: - vault + networks: + - secure-network + # Enhanced security configurations + sysctls: + - net.ipv4.conf.all.rp_filter=1 + - net.ipv4.conf.all.secure_redirects=0 + ulimits: + nproc: 65536 + nofile: 65536 + # Security-focused health check + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8082/health"] + interval: 30s + timeout: 10s + retries: 3 + # Resource limits for security + deploy: + resources: + limits: + memory: 512M + cpus: "0.5" + reservations: + memory: 256M + cpus: "0.25" + +networks: + secure-network: + driver: bridge diff --git a/internal/config.go b/internal/config.go index c7e7193..ac75988 100644 --- a/internal/config.go +++ b/internal/config.go @@ -1,3 +1,5 @@ +// Package internal contains the core business logic for the sup3rS3cretMes5age application, +// including configuration management, HTTP handlers, server setup, and Vault integration. package internal import ( @@ -6,24 +8,50 @@ import ( "strings" ) +// conf holds the application configuration settings loaded from environment variables. +// It includes HTTP/HTTPS binding addresses, TLS configuration, and Vault storage prefix. type conf struct { - HttpBindingAddress string - HttpsBindingAddress string + // HttpBindingAddress is the HTTP server binding address (e.g., ":8080"). + HttpBindingAddress string + // HttpsBindingAddress is the HTTPS server binding address (e.g., ":443"). + HttpsBindingAddress string + // HttpsRedirectEnabled determines whether HTTP requests should redirect to HTTPS. HttpsRedirectEnabled bool - TLSAutoDomain string - TLSCertFilepath string - TLSCertKeyFilepath string - VaultPrefix string + // TLSAutoDomain is the domain for automatic Let's Encrypt TLS certificate generation. + TLSAutoDomain string + // TLSCertFilepath is the path to a manual TLS certificate file. + TLSCertFilepath string + // TLSCertKeyFilepath is the path to a manual TLS certificate key file. + TLSCertKeyFilepath string + // VaultPrefix is the Vault storage path prefix (defaults to "cubbyhole/"). + VaultPrefix string + // AllowedOrigins is the list of allowed CORS origins. + AllowedOrigins []string } -const HttpBindingAddressVarenv = "SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS" -const HttpsBindingAddressVarenv = "SUPERSECRETMESSAGE_HTTPS_BINDING_ADDRESS" -const HttpsRedirectEnabledVarenv = "SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED" -const TLSAutoDomainVarenv = "SUPERSECRETMESSAGE_TLS_AUTO_DOMAIN" -const TLSCertFilepathVarenv = "SUPERSECRETMESSAGE_TLS_CERT_FILEPATH" -const TLSCertKeyFilepathVarenv = "SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH" -const VaultPrefixenv = "SUPERSECRETMESSAGE_VAULT_PREFIX" +// Environment variable names for application configuration. +const ( + // HttpBindingAddressVarenv is the environment variable for HTTP binding address. + HttpBindingAddressVarenv = "SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS" + // HttpsBindingAddressVarenv is the environment variable for HTTPS binding address. + HttpsBindingAddressVarenv = "SUPERSECRETMESSAGE_HTTPS_BINDING_ADDRESS" + // HttpsRedirectEnabledVarenv is the environment variable to enable HTTPS redirect. + HttpsRedirectEnabledVarenv = "SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED" + // TLSAutoDomainVarenv is the environment variable for automatic TLS domain. + TLSAutoDomainVarenv = "SUPERSECRETMESSAGE_TLS_AUTO_DOMAIN" + // TLSCertFilepathVarenv is the environment variable for manual TLS certificate path. + TLSCertFilepathVarenv = "SUPERSECRETMESSAGE_TLS_CERT_FILEPATH" + // TLSCertKeyFilepathVarenv is the environment variable for manual TLS key path. + TLSCertKeyFilepathVarenv = "SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH" + // VaultPrefixenv is the environment variable for Vault storage prefix. + VaultPrefixenv = "SUPERSECRETMESSAGE_VAULT_PREFIX" + // AllowedOriginsVarenv is the environment variable for allowed CORS origins. + AllowedOriginsVarenv = "SUPERSECRETMESSAGE_ALLOWED_ORIGINS" +) +// LoadConfig loads and validates application configuration from environment variables. +// It validates TLS configuration mutual exclusivity, ensures required bindings are set, +// and sets default values where appropriate. Exits with fatal error on invalid configuration. func LoadConfig() conf { var cnf conf @@ -34,6 +62,7 @@ func LoadConfig() conf { cnf.TLSCertFilepath = os.Getenv(TLSCertFilepathVarenv) cnf.TLSCertKeyFilepath = os.Getenv(TLSCertKeyFilepathVarenv) cnf.VaultPrefix = os.Getenv(VaultPrefixenv) + cnf.AllowedOrigins = strings.Split(os.Getenv(AllowedOriginsVarenv), ",") if cnf.TLSAutoDomain != "" && (cnf.TLSCertFilepath != "" || cnf.TLSCertKeyFilepath != "") { log.Fatalf("Auto TLS (%s) is mutually exclusive with manual TLS (%s and %s)", TLSAutoDomainVarenv, @@ -72,6 +101,7 @@ func LoadConfig() conf { log.Println("[INFO] TLS Cert Filepath:", cnf.TLSCertFilepath) log.Println("[INFO] TLS Cert Key Filepath:", cnf.TLSCertKeyFilepath) log.Println("[INFO] Vault prefix:", cnf.VaultPrefix) + log.Println("[INFO] Allowed Origins:", cnf.AllowedOrigins) return cnf } diff --git a/internal/config_test.go b/internal/config_test.go new file mode 100644 index 0000000..081477b --- /dev/null +++ b/internal/config_test.go @@ -0,0 +1,26 @@ +package internal + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoadConfig(t *testing.T) { + _ = os.Setenv(HttpBindingAddressVarenv, ":8080") + _ = os.Setenv(VaultPrefixenv, "cubbyhole/") + _ = os.Setenv(AllowedOriginsVarenv, "http://localhost,https://example.com") + defer func() { + _ = os.Unsetenv(HttpBindingAddressVarenv) + _ = os.Unsetenv(VaultPrefixenv) + _ = os.Unsetenv(AllowedOriginsVarenv) + }() + + cnf := LoadConfig() + + assert.Equal(t, ":8080", cnf.HttpBindingAddress) + assert.Equal(t, "cubbyhole/", cnf.VaultPrefix) + assert.False(t, cnf.HttpsRedirectEnabled) + assert.Equal(t, []string{"http://localhost", "https://example.com"}, cnf.AllowedOrigins) +} diff --git a/internal/handlers.go b/internal/handlers.go index 83c24b2..b78e2ad 100644 --- a/internal/handlers.go +++ b/internal/handlers.go @@ -2,39 +2,140 @@ package internal import ( "encoding/base64" + "fmt" "io" + "mime" + "mime/multipart" "net/http" + "path/filepath" + "regexp" + "strings" + "time" "github.com/labstack/echo/v4" ) +// tokenRegex matches valid Vault token formats for hv.sb and legacy tokens. +var tokenRegex = regexp.MustCompile(`^hv[sb]\.(?:[A-Za-z0-9]{24}|[A-Za-z0-9_-]{91,})$`) + +// TokenResponse represents the API response when creating a new secret message. +// It includes a token for retrieving the message, and optional file token and name +// if a file was uploaded alongside the message. type TokenResponse struct { - Token string `json:"token"` + // Token is the unique identifier for retrieving the secret message. + Token string `json:"token"` + // FileToken is the unique identifier for retrieving an uploaded file (optional). FileToken string `json:"filetoken,omitempty"` - FileName string `json:"filename,omitempty"` + // FileName is the original name of the uploaded file (optional). + FileName string `json:"filename,omitempty"` } +// MsgResponse represents the API response when retrieving a secret message. type MsgResponse struct { + // Msg is the secret message content retrieved from Vault. Msg string `json:"msg"` } +// SecretHandlers provides HTTP handler methods for creating and retrieving secret messages. type SecretHandlers struct { + // store is the backend storage implementation (Vault) for secret messages. store SecretMsgStorer } -func newSecretHandlers(s SecretMsgStorer) *SecretHandlers { +// NewSecretHandlers creates a new SecretHandlers instance with the provided storage backend. +func NewSecretHandlers(s SecretMsgStorer) *SecretHandlers { return &SecretHandlers{s} } +// validateMsg checks if the provided message is non-empty and within size limits. +func validateMsg(msg string) error { + if msg == "" { + return fmt.Errorf("message is required") + } + + // 1MB limit for text + if len(msg) > 1*1024*1024 { + return fmt.Errorf("message too large") + } + + return nil +} + +// isValidTTL checks if the provided TTL string is a valid duration between 1 minute and 7 days. +func isValidTTL(ttl string) bool { + // Verify duration + d, err := time.ParseDuration(ttl) + if err != nil { + return false + } + + // validate duration length (between 1 minute and 7 days) + if d < 1*time.Minute || d > 168*time.Hour { + return false + } + return true +} + +// validateFileUpload checks the uploaded file for size and filename validity. +func validateFileUpload(file *multipart.FileHeader) error { + // Parse Content-Disposition to extract filename + mediatype, params, err := mime.ParseMediaType(file.Header.Get("Content-Disposition")) + if mediatype != "form-data" || err != nil { + return fmt.Errorf("invalid file upload") + } + + // Check file size + if file.Size > 50*1024*1024 { + return fmt.Errorf("file too large") + } + + // Check filename for path traversal + if strings.Contains(params["filename"], "..") || + strings.Contains(params["filename"], "/") || + strings.Contains(params["filename"], "\\") || + strings.Contains(file.Filename, "..") || + strings.Contains(file.Filename, "/") || + strings.Contains(file.Filename, "\\") { + return fmt.Errorf("invalid filename") + } + + return nil +} + +// validateVaultToken checks the format of Vault-generated tokens +func validateVaultToken(token string) error { + // Check token format + if !tokenRegex.MatchString(token) { + return fmt.Errorf("invalid token format: %s", token) + } + return nil +} + +// CreateMsgHandler handles POST requests to create a new self-destructing secret message. +// It accepts form data with 'msg' (required), 'ttl' (optional time-to-live), and 'file' (optional file upload). +// Files are base64 encoded before storage. Maximum file size is 50MB (enforced by middleware). +// Returns a JSON response with token(s) for retrieving the message and/or file. func (s SecretHandlers) CreateMsgHandler(ctx echo.Context) error { - var tr TokenResponse - //Get TTL (if any) + msg := ctx.FormValue("msg") + if err := validateMsg(msg); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // Get TTL (if any) ttl := ctx.FormValue("ttl") + if ttl != "" && !isValidTTL(ttl) { + return echo.NewHTTPError(http.StatusBadRequest, "invalid TTL format") + } + var tr TokenResponse // Upload file if any file, err := ctx.FormFile("file") if err == nil { + if err := validateFileUpload(file); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + src, err := file.Open() if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err) @@ -59,19 +160,28 @@ func (s SecretHandlers) CreateMsgHandler(ctx echo.Context) error { } // Handle the secret message - msg := ctx.FormValue("msg") tr.Token, err = s.store.Store(msg, ttl) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err) + ctx.Logger().Errorf("Failed to store secret: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, "failed to store secret") } return ctx.JSON(http.StatusOK, tr) } +// GetMsgHandler handles GET requests to retrieve a self-destructing secret message. +// Accepts a 'token' query parameter. The message is deleted from Vault after retrieval, +// making it accessible only once. Returns a JSON response with the message content. func (s SecretHandlers) GetMsgHandler(ctx echo.Context) error { - m, err := s.store.Get(ctx.QueryParam("token")) + token := ctx.QueryParam("token") + if err := validateVaultToken(token); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + m, err := s.store.Get(token) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err) + ctx.Logger().Errorf("Failed to retrieve secret: %v", err) + return echo.NewHTTPError(http.StatusNotFound, "secret not found or already consumed") } r := &MsgResponse{ Msg: m, @@ -79,10 +189,131 @@ func (s SecretHandlers) GetMsgHandler(ctx echo.Context) error { return ctx.JSON(http.StatusOK, r) } +// healthHandler provides a simple health check endpoint. +// Returns HTTP 200 OK when the application is running. func healthHandler(ctx echo.Context) error { return ctx.String(http.StatusOK, http.StatusText(http.StatusOK)) } +// redirectHandler redirects the root path to the message creation page. func redirectHandler(ctx echo.Context) error { return ctx.Redirect(http.StatusPermanentRedirect, "/msg") } + +// isValidLanguage checks if the provided language code is supported. +func isValidLanguage(lang string) bool { + validLanguages := []string{"en", "fr", "es", "de", "it"} + for _, valid := range validLanguages { + if valid == lang { + return true + } + } + return false +} + +// htmlHandler serves HTML files with language preference handling. +func htmlHandler(ctx echo.Context, path string) error { + // Check for language preference in cookie or header + lang := ctx.QueryParam("lang") + if lang == "" { + lang = ctx.Request().Header.Get("Accept-Language") + if lang != "" { + // Extract primary language (e.g., "en-US,en;q=0.9" -> "en") + lang = strings.Split(lang, ",")[0] + lang = strings.Split(lang, "-")[0] + } + } + + // Set default language if none found + if lang == "" || !isValidLanguage(lang) { + lang = "en" + } + + // Pass language to template context + ctx.Response().Header().Set("Content-Language", lang) + ctx.Response().Header().Set("Cache-Control", "public, max-age=300, must-revalidate") + ctx.Response().Header().Set("Vary", "Accept-Encoding") + return ctx.File(path) +} + +// indexHandler serves the main message creation HTML page. +func indexHandler(ctx echo.Context) error { + return htmlHandler(ctx, "static/index.html") +} + +// getmsgHandler serves the message retrieval HTML page. +func getmsgHandler(ctx echo.Context) error { + return htmlHandler(ctx, "static/getmsg.html") +} + +// getCleanedPath sanitizes and validates the requested static file path. +func getCleanedPath(ctx echo.Context) (string, error) { + // Get URL path (without query string) + urlPath := ctx.Request().URL.Path + + // Remove leading slash and clean + path := filepath.Clean(strings.TrimPrefix(urlPath, "/")) + + // Security: ensure path starts with "static/" after cleaning + if !strings.HasPrefix(path, "static/") && path != "static" { + return "", echo.NewHTTPError(http.StatusForbidden, "access denied") + } + + return path, nil +} + +// shortCacheHandler serves static files with short-term (5 minutes) caching headers. +func shortCacheHandler(ctx echo.Context) error { + path, err := getCleanedPath(ctx) + if err != nil { + return err + } + + if strings.HasSuffix(path, ".js") { + ctx.Response().Header().Set("Content-Type", "application/javascript; charset=utf-8") + } else if strings.HasSuffix(path, ".css") { + ctx.Response().Header().Set("Content-Type", "text/css; charset=utf-8") + } + ctx.Response().Header().Set("Cache-Control", "public, max-age=300, must-revalidate") + ctx.Response().Header().Set("Vary", "Accept-Encoding") + return ctx.File(path) +} + +// mediumCacheHandler serves static files with medium-term (1 hour) caching headers. +func mediumCacheHandler(ctx echo.Context) error { + path, err := getCleanedPath(ctx) + if err != nil { + return err + } + + if strings.HasSuffix(path, ".json") { + ctx.Response().Header().Set("Content-Type", "application/json") + } + ctx.Response().Header().Set("Cache-Control", "public, max-age=3600, must-revalidate") + ctx.Response().Header().Set("Vary", "Accept-Encoding") + return ctx.File(path) +} + +// longCacheHandler serves static files with long-term (24 hours) caching headers. +func longCacheHandler(ctx echo.Context) error { + path, err := getCleanedPath(ctx) + if err != nil { + return err + } + + ctx.Response().Header().Set("Cache-Control", "public, max-age=86400, must-revalidate") + ctx.Response().Header().Set("Vary", "Accept-Encoding") + return ctx.File(path) +} + +// fontCacheHandler serves font files with long-term immutable caching. +func fontCacheHandler(ctx echo.Context) error { + path, err := getCleanedPath(ctx) + if err != nil { + return err + } + + ctx.Response().Header().Set("Cache-Control", "public, max-age=2592000, immutable") + ctx.Response().Header().Set("Vary", "Accept-Encoding") + return ctx.File(path) +} diff --git a/internal/handlers_test.go b/internal/handlers_test.go index ed6b3a5..4a3bd1f 100644 --- a/internal/handlers_test.go +++ b/internal/handlers_test.go @@ -1,9 +1,14 @@ package internal import ( + "bytes" + "encoding/json" "errors" + "mime/multipart" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "github.com/labstack/echo/v4" @@ -28,36 +33,69 @@ func (f *FakeSecretMsgStorer) Store(msg string, ttl string) (token string, err e return f.token, f.err } -func TestGetMsgHandlerSuccess(t *testing.T) { - e := echo.New() - req := httptest.NewRequest(http.MethodGet, "/?token=secrettoken", nil) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) - - s := &FakeSecretMsgStorer{msg: "secret"} - h := newSecretHandlers(s) - err := h.GetMsgHandler(c) - - assert.NoError(t, err) - assert.Equal(t, "secrettoken", s.lastUsedToken) - assert.Equal(t, http.StatusOK, rec.Code) - assert.Equal(t, "{\"msg\":\"secret\"}\n", rec.Body.String()) -} +func TestGetMsgHandler(t *testing.T) { + tests := []struct { + name string + token string + storedMsg string + storeErr error + expectedStatus int + expectedMsg string + expectError bool + }{ + { + name: "successful message retrieval", + token: "hvs.CABAAAAAAQAAAAAAAAAABBBB", + storedMsg: "secret", + storeErr: nil, + expectedStatus: http.StatusOK, + expectedMsg: "{\"msg\":\"secret\"}\n", + expectError: false, + }, + { + name: "message retrieval with error", + token: "hvs.CABAAAAAAQAAAAAAAAAABBBB", + storedMsg: "secret", + storeErr: errors.New("expired"), + expectedStatus: http.StatusNotFound, + expectedMsg: "", + expectError: true, + }, + { + name: "invalid token format", + token: "invalid-token-123", + storedMsg: "", + storeErr: nil, + expectedStatus: http.StatusBadRequest, + expectedMsg: "", + expectError: true, + }, + } -func TestGetMsgHandlerError(t *testing.T) { - e := echo.New() - req := httptest.NewRequest(http.MethodGet, "/?token=secrettoken", nil) - rec := httptest.NewRecorder() - c := e.NewContext(req, rec) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/?token="+tt.token, nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) - s := &FakeSecretMsgStorer{msg: "secret", err: errors.New("expired")} - h := newSecretHandlers(s) - err := h.GetMsgHandler(c) + s := &FakeSecretMsgStorer{msg: tt.storedMsg, err: tt.storeErr} + h := NewSecretHandlers(s) + err := h.GetMsgHandler(c) - assert.Error(t, err) - if assert.IsType(t, &echo.HTTPError{}, err) { - v, _ := err.(*echo.HTTPError) - assert.Equal(t, http.StatusInternalServerError, v.Code) + if tt.expectError { + assert.Error(t, err) + if assert.IsType(t, &echo.HTTPError{}, err) { + v, _ := err.(*echo.HTTPError) + assert.Equal(t, tt.expectedStatus, v.Code) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.token, s.lastUsedToken) + assert.Equal(t, tt.expectedStatus, rec.Code) + assert.Equal(t, tt.expectedMsg, rec.Body.String()) + } + }) } } @@ -85,3 +123,240 @@ func TestRedirectHandler(t *testing.T) { assert.Equal(t, http.StatusPermanentRedirect, rec.Code) assert.Equal(t, "/msg", rec.Result().Header.Get("Location")) } + +func TestIsValidTTL(t *testing.T) { + tests := []struct { + ttl string + valid bool + }{ + {"1h", true}, + {"30m", true}, + {"2h30m", true}, + {"48h", true}, + {"168h", true}, // 7 days - maximum + {"169h", false}, // exceeds maximum + {"30s", false}, // below minimum + {"0h", false}, // zero duration + {"", false}, // empty + {"invalid", false}, // invalid format + {"1d", false}, // 'd' not supported by Go + {"-1h", false}, // negative duration + } + + for _, tt := range tests { + result := isValidTTL(tt.ttl) + assert.Equal(t, result, tt.valid) + } +} +func TestValidateMsg(t *testing.T) { + tests := []struct { + name string + msg string + wantErr bool + }{ + {"valid message", "test secret", false}, + {"empty message", "", true}, + {"message too large", strings.Repeat("a", 1024*1024+1), true}, + {"message at limit", strings.Repeat("a", 1024*1024), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateMsg(tt.msg) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestCreateMsgHandler(t *testing.T) { + tests := []struct { + name string + msg string + ttl string + errMessage string + }{ + {"valid message and ttl", "hello world", "1h", ""}, + {"valid message, no ttl", "hello world", "", ""}, + {"empty message", "", "1h", "message is required"}, + {"message too large", strings.Repeat("a", 1024*1024+1), "1h", "message too large"}, + {"invalid ttl", "hello world", "30s", "invalid TTL format"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := echo.New() + form := make(url.Values) + form.Set("msg", tt.msg) + form.Set("ttl", tt.ttl) + + req := httptest.NewRequest(http.MethodPost, "/secret", strings.NewReader(form.Encode())) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + s := &FakeSecretMsgStorer{token: "testtoken"} + h := NewSecretHandlers(s) + err := h.CreateMsgHandler(c) + + if tt.errMessage != "" { + assert.Error(t, err) + if httpErr, ok := err.(*echo.HTTPError); ok { + assert.Equal(t, http.StatusBadRequest, httpErr.Code) + assert.Equal(t, tt.errMessage, httpErr.Message) + } + } else { + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, tt.msg, s.lastMsg) + } + }) + } +} + +func TestCreateMsgHandlerWithFile(t *testing.T) { + tests := []struct { + name string + msg string + ttl string + fileName string + fileContent []byte + expectError bool + expectedCode int + checkToken bool + checkFile bool + }{ + { + name: "valid message with file", + msg: "secret message", + ttl: "1h", + fileName: "test.txt", + fileContent: []byte("file content"), + expectError: false, + expectedCode: http.StatusOK, + checkToken: true, + checkFile: true, + }, + { + name: "valid message with file, no TTL", + msg: "secret message", + ttl: "", + fileName: "document.pdf", + fileContent: []byte("PDF content here"), + expectError: false, + expectedCode: http.StatusOK, + checkToken: true, + checkFile: true, + }, + { + name: "empty file should not create file token", + msg: "secret message", + ttl: "1h", + fileName: "empty.txt", + fileContent: []byte{}, + expectError: false, + expectedCode: http.StatusOK, + checkToken: true, + checkFile: false, + }, + { + name: "file with path traversal", + msg: "secret message", + ttl: "1h", + fileName: "../etc/passwd", + fileContent: []byte("malicious"), + expectError: true, + expectedCode: http.StatusBadRequest, + }, + { + name: "file with slash in name", + msg: "secret message", + ttl: "1h", + fileName: "path/to/file.txt", + fileContent: []byte("content"), + expectError: true, + expectedCode: http.StatusBadRequest, + }, + { + name: "file too big", + msg: "secret message", + ttl: "1h", + fileName: "bigfile.txt", + fileContent: make([]byte, 50*1024*1024+1), // 50MB + 1 byte + expectError: true, + expectedCode: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add message field + err := writer.WriteField("msg", tt.msg) + assert.NoError(t, err) + + // Add TTL field if provided + if tt.ttl != "" { + err = writer.WriteField("ttl", tt.ttl) + assert.NoError(t, err) + } + + // Add file field + part, err := writer.CreateFormFile("file", tt.fileName) + assert.NoError(t, err) + _, err = part.Write(tt.fileContent) + assert.NoError(t, err) + + err = writer.Close() + assert.NoError(t, err) + + // Create request + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/secret", body) + req.Header.Set(echo.HeaderContentType, writer.FormDataContentType()) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + // Create fake store that returns tokens + s := &FakeSecretMsgStorer{token: "msg-token-123"} + h := NewSecretHandlers(s) + + // Execute handler + handlerErr := h.CreateMsgHandler(c) + + if tt.expectError { + assert.Error(t, handlerErr) + if httpErr, ok := handlerErr.(*echo.HTTPError); ok { + assert.Equal(t, tt.expectedCode, httpErr.Code) + } + } else { + assert.NoError(t, handlerErr) + assert.Equal(t, tt.expectedCode, rec.Code) + + // Parse response + var response TokenResponse + err := json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + + if tt.checkToken { + assert.Equal(t, "msg-token-123", response.Token) + assert.Equal(t, tt.msg, s.lastMsg) + } + + if tt.checkFile { + assert.NotEmpty(t, response.FileToken) + assert.Equal(t, tt.fileName, response.FileName) + } else { + assert.Empty(t, response.FileToken) + assert.Empty(t, response.FileName) + } + } + }) + } +} diff --git a/internal/server.go b/internal/server.go index 397f980..a0944ff 100644 --- a/internal/server.go +++ b/internal/server.go @@ -1,8 +1,13 @@ +// Package internal provides HTTP server setup and request handlers for the sup3rS3cretMes5age application. +// It includes server lifecycle management with graceful shutdown, middleware configuration, +// route setup, and integration with HashiCorp Vault for secure message storage. package internal import ( + "context" "crypto/tls" "net/http" + "time" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -10,67 +15,132 @@ import ( "golang.org/x/crypto/acme/autocert" ) -func Serve(cnf conf) { - // Vault address and token are taken from VAULT_ADDR and VAULT_TOKEN environment variables - handlers := newSecretHandlers(newVault("", cnf.VaultPrefix, "")) - e := echo.New() +// Server encapsulates the HTTP/HTTPS server configuration and lifecycle management. +// It provides testable server initialization and graceful shutdown capabilities. +type Server struct { + echo *echo.Echo + config conf + handlers *SecretHandlers + httpServer *http.Server + httpsServer *http.Server +} - if cnf.HttpsRedirectEnabled { - e.Pre(middleware.HTTPSRedirect()) - } +// NewServer creates a new Server instance with the provided configuration and handlers. +// It configures Echo with all middleware and routes but does not start the server. +// This allows the server to be tested without binding to network ports. +func NewServer(cnf conf, handlers *SecretHandlers) *Server { + e := echo.New() + e.HideBanner = true + // Configure Auto TLS if enabled if cnf.TLSAutoDomain != "" { e.AutoTLSManager.HostPolicy = autocert.HostWhitelist(cnf.TLSAutoDomain) e.AutoTLSManager.Cache = autocert.DirCache("/var/www/.cache") } - // Limit to 10 RPS (only human should use this service) - e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(10))) - // do not log the /health endpoint - e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ - Skipper: func(c echo.Context) bool { - return c.Path() == "/health" - }, - })) - e.Use(middleware.BodyLimit("50M")) - e.Use(middleware.Secure()) - e.Use(middleware.Recover()) + s := &Server{ + echo: e, + config: cnf, + handlers: handlers, + } - e.GET("/", redirectHandler) - e.File("/robots.txt", "static/robots.txt") + setupMiddlewares(e, cnf) + setupRoutes(e, handlers) - e.Any("/health", healthHandler) - e.GET("/secret", handlers.GetMsgHandler) - e.POST("/secret", handlers.CreateMsgHandler) - e.File("/msg", "static/index.html") - e.File("/getmsg", "static/getmsg.html") - e.Static("/static", "static") - - if cnf.HttpBindingAddress != "" { - if cnf.HttpsBindingAddress != "" { - go func(c *echo.Echo) { - e.Logger.Fatal(e.Start(cnf.HttpBindingAddress)) - }(e) + return s +} + +// Start begins listening for HTTP and/or HTTPS requests based on configuration. +// It supports three modes: +// 1. HTTP only (when only HttpBindingAddress is set) +// 2. HTTPS only with Auto TLS or Manual TLS +// 3. Both HTTP and HTTPS (HTTP typically for redirect) +// +// The function blocks until the server is shut down via context cancellation +// or encounters a fatal error. +func (s *Server) Start(ctx context.Context) error { + // Channel to collect errors from goroutines + errChan := make(chan error, 2) + + // Start HTTP server if configured + if s.config.HttpBindingAddress != "" { + if s.config.HttpsBindingAddress != "" { + // Both HTTP and HTTPS - run HTTP in goroutine + go func() { + if err := s.startHTTP(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() } else { - e.Logger.Fatal(e.Start(cnf.HttpBindingAddress)) + // HTTP only + go func() { + if err := s.startHTTP(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() } } + // Start HTTPS server if TLS is configured + if s.config.HttpsBindingAddress != "" || s.config.TLSAutoDomain != "" || s.config.TLSCertFilepath != "" { + go func() { + if err := s.startHTTPS(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + } + + // Wait for context cancellation or error + select { + case <-ctx.Done(): + return s.Shutdown(context.Background()) + case err := <-errChan: + return err + } +} + +// startHTTP starts the HTTP server on the configured binding address. +func (s *Server) startHTTP() error { + s.httpServer = &http.Server{ + Addr: s.config.HttpBindingAddress, + Handler: s.echo, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1MB + } + + s.echo.Logger.Infof("Starting HTTP server on %s", s.config.HttpBindingAddress) + return s.httpServer.ListenAndServe() +} + +// startHTTPS starts the HTTPS server with TLS configuration. +// Supports both automatic TLS (Let's Encrypt) and manual certificate configuration. +func (s *Server) startHTTPS() error { autoTLSManager := autocert.Manager{ Prompt: autocert.AcceptTOS, - // Cache certificates to avoid issues with rate limits (https://letsencrypt.org/docs/rate-limits) - Cache: autocert.DirCache("/var/www/.cache"), - //HostPolicy: autocert.HostWhitelist(""), + Cache: autocert.DirCache("/var/www/.cache"), + } + + // Use HTTPS binding address if set, otherwise default to :443 + addr := s.config.HttpsBindingAddress + if addr == "" { + addr = ":443" } - s := http.Server{ - Addr: ":443", - Handler: e, // set Echo as handler + + s.httpsServer = &http.Server{ + Addr: addr, + Handler: s.echo, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1MB TLSConfig: &tls.Config{ - //Certificates: nil, // <-- s.ListenAndServeTLS will populate this field - GetCertificate: autoTLSManager.GetCertificate, - NextProtos: []string{acme.ALPNProto}, - MinVersion: tls.VersionTLS12, - CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.X25519, tls.CurveP256}, + GetCertificate: autoTLSManager.GetCertificate, + NextProtos: []string{acme.ALPNProto}, + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.X25519, tls.CurveP256}, + PreferServerCipherSuites: true, CipherSuites: []uint16{ // TLS 1.2 safe cipher suites tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, @@ -84,11 +154,128 @@ func Serve(cnf conf) { tls.TLS_AES_256_GCM_SHA384, tls.TLS_CHACHA20_POLY1305_SHA256, }, - PreferServerCipherSuites: true, }, - //ReadTimeout: 30 * time.Second, // use custom timeouts } - if err := s.ListenAndServeTLS("", ""); err != http.ErrServerClosed { - e.Logger.Fatal(err) + + s.echo.Logger.Infof("Starting HTTPS server on %s", addr) + + // Start with manual certificates if provided, otherwise use auto TLS + if s.config.TLSCertFilepath != "" && s.config.TLSCertKeyFilepath != "" { + return s.httpsServer.ListenAndServeTLS(s.config.TLSCertFilepath, s.config.TLSCertKeyFilepath) + } + + return s.httpsServer.ListenAndServeTLS("", "") +} + +// Shutdown gracefully shuts down the server without interrupting active connections. +// It stops accepting new requests and waits for existing requests to complete +// within the provided context timeout. +func (s *Server) Shutdown(ctx context.Context) error { + s.echo.Logger.Info("Shutting down server...") + + if s.httpServer != nil { + if err := s.httpServer.Shutdown(ctx); err != nil { + s.echo.Logger.Errorf("HTTP server shutdown error: %v", err) + } } + + if s.httpsServer != nil { + if err := s.httpsServer.Shutdown(ctx); err != nil { + s.echo.Logger.Errorf("HTTPS server shutdown error: %v", err) + } + } + + return s.echo.Shutdown(ctx) +} + +// handler returns the underlying http.Handler for testing purposes. +// This allows tests to use httptest.ResponseRecorder without starting a real server. +func (s *Server) handler() http.Handler { + return s.echo +} + +// setupMiddlewares configures Echo's middleware stack with security, rate limiting, and logging. +// It applies HTTPS redirect (if enabled), CORS policy, rate limiting (5 RPS), request logging, +// security headers (CSP, XSS protection, HSTS), body size limits (50MB), and panic recovery. +// Middleware is applied in order: pre-routing (HTTPS redirect), then request-level middleware. +func setupMiddlewares(e *echo.Echo, cnf conf) { + if cnf.HttpsRedirectEnabled { + e.Pre(middleware.HTTPSRedirect()) + } + + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: cnf.AllowedOrigins, + AllowMethods: []string{http.MethodGet, http.MethodPost}, + AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType}, + MaxAge: 86400, + })) + + // Enable Gzip compression for all responses + e.Use(middleware.Gzip()) + + // Limit to 5 RPS (burst 10) (only human should use this service) + e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ + Store: middleware.NewRateLimiterMemoryStoreWithConfig( + middleware.RateLimiterMemoryStoreConfig{ + Rate: 10, + Burst: 20, + ExpiresIn: 1 * time.Minute, + }, + ), + IdentifierExtractor: func(ctx echo.Context) (string, error) { + return ctx.RealIP(), nil + }, + DenyHandler: func(ctx echo.Context, identifier string, err error) error { + return ctx.JSON(http.StatusTooManyRequests, map[string]string{ + "error": "rate limit exceeded", + }) + }, + })) + + // do not log the /health endpoint + e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ + Skipper: func(c echo.Context) bool { + return c.Path() == "/health" + }, + })) + + e.Use(middleware.SecureWithConfig(middleware.SecureConfig{ + XSSProtection: "1; mode=block", + ContentTypeNosniff: "nosniff", + XFrameOptions: "DENY", + HSTSMaxAge: 31536000, + HSTSPreloadEnabled: true, + ContentSecurityPolicy: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; frame-ancestors 'none'", + })) + + e.Use(middleware.BodyLimit("50M")) + + e.Use(middleware.Recover()) +} + +// setupRoutes registers all HTTP endpoints and static file routes. +// API endpoints: GET/POST /secret (secret management), ANY /health (health check), GET / (redirect). +// Static routes: /msg and /getmsg (HTML pages), /static (assets), /robots.txt (SEO). +func setupRoutes(e *echo.Echo, handlers *SecretHandlers) { + e.GET("/", redirectHandler) + + e.File("/robots.txt", "static/robots.txt") + + e.Any("/health", healthHandler) + + // API secret endpoints + e.GET("/secret", handlers.GetMsgHandler) + e.POST("/secret", handlers.CreateMsgHandler) + + // HTML page handlers + e.GET("/msg", indexHandler) + e.GET("/getmsg", getmsgHandler) + + // Static assets with tiered caching + static := e.Group("/static") + staticMethods := []string{"GET", "HEAD"} + static.Match(staticMethods, "/fonts/*", fontCacheHandler) + static.Match(staticMethods, "/icons/*", longCacheHandler) + static.Match(staticMethods, "/locales/*", mediumCacheHandler) + static.Match(staticMethods, "/*", shortCacheHandler) } diff --git a/internal/server_test.go b/internal/server_test.go new file mode 100644 index 0000000..4b3fc04 --- /dev/null +++ b/internal/server_test.go @@ -0,0 +1,301 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/acme/autocert" +) + +func TestNewServer(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + + server := NewServer(cnf, handlers) + + assert.NotNil(t, server) + assert.NotNil(t, server.echo) + assert.NotNil(t, server.handlers) + assert.Equal(t, cnf.HttpBindingAddress, server.config.HttpBindingAddress) +} + +func TestServerHandler(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + // Test health endpoint + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + server.handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "OK", rec.Body.String()) +} + +func TestServerRoutesRegistered(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + routes := server.echo.Routes() + assert.NotEmpty(t, routes) + + // Verify key routes exist + routeMap := make(map[string]bool) + for _, route := range routes { + key := route.Method + " " + route.Path + routeMap[key] = true + } + + assert.True(t, routeMap["POST /secret"], "POST /secret should be registered") + assert.True(t, routeMap["GET /secret"], "GET /secret should be registered") + assert.True(t, routeMap["GET /health"] || routeMap["POST /health"], "/health should be registered") + assert.True(t, routeMap["GET /"], "GET / should be registered") +} + +func TestServerWithMiddlewares(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"http://localhost:3000"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + // Test CORS middleware + req := httptest.NewRequest(http.MethodOptions, "/secret", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "POST") + rec := httptest.NewRecorder() + server.handler().ServeHTTP(rec, req) + + assert.Equal(t, "http://localhost:3000", rec.Header().Get("Access-Control-Allow-Origin")) +} + +func TestServerSecurityHeaders(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + server.handler().ServeHTTP(rec, req) + + // Verify security headers + assert.Equal(t, "1; mode=block", rec.Header().Get("X-XSS-Protection")) + assert.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options")) + assert.Equal(t, "DENY", rec.Header().Get("X-Frame-Options")) + assert.Contains(t, rec.Header().Get("Content-Security-Policy"), "default-src 'self'") +} + +func TestServerRedirect(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + server.handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusPermanentRedirect, rec.Code) + assert.Equal(t, "/msg", rec.Header().Get("Location")) +} + +func TestServerWithTLSAutoDomain(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + TLSAutoDomain: "example.com", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + assert.NotNil(t, server) + // Verify TLS domain is configured (checking the pointer to avoid copylocks) + assert.NotNil(t, server.echo) + assert.Equal(t, "example.com", server.config.TLSAutoDomain) + assert.Equal(t, autocert.DirCache("/var/www/.cache"), server.echo.AutoTLSManager.Cache) +} + +func TestServerGracefulShutdown(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := server.Shutdown(ctx) + assert.NoError(t, err) +} + +func TestServerHandlersIntegration(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + // Use valid Vault token format (hvs. prefix + 24 alphanumeric chars) + validToken := "hvs.CABAAAAAAQAAAAAAAAAABBBB" + storage := &FakeSecretMsgStorer{ + token: validToken, + msg: "secret message", + } + handlers := NewSecretHandlers(storage) + server := NewServer(cnf, handlers) + + // Test GET /secret with valid token + req := httptest.NewRequest(http.MethodGet, "/secret?token="+validToken, nil) + rec := httptest.NewRecorder() + server.handler().ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "secret message") +} + +func TestServerRateLimiting(t *testing.T) { + if testing.Short() { + t.Skip("Skipping rate limit test in short mode") + } + + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + handlers := NewSecretHandlers(&FakeSecretMsgStorer{}) + server := NewServer(cnf, handlers) + + // Make rapid requests to trigger rate limit + successCount := 0 + rateLimitCount := 0 + + for i := 0; i < 30; i++ { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("X-Real-IP", "192.168.1.1") + rec := httptest.NewRecorder() + server.handler().ServeHTTP(rec, req) + + switch rec.Code { + case http.StatusOK: + successCount++ + case http.StatusTooManyRequests: + rateLimitCount++ + } + } + + // Should have some rate limited requests + assert.Greater(t, rateLimitCount, 0, "Rate limiter should have triggered") +} + +func TestServerGzipCompression(t *testing.T) { + validToken := "hvs.CABAAAAAAQAAAAAAAAAABBBB" + + tests := []struct { + name string + path string + acceptEncoding string + setupStorage func() *FakeSecretMsgStorer + expectedStatus int + expectGzip bool + checkVary bool + }{ + { + name: "health endpoint with gzip support", + path: "/health", + acceptEncoding: "gzip", + setupStorage: func() *FakeSecretMsgStorer { return &FakeSecretMsgStorer{} }, + expectedStatus: http.StatusOK, + expectGzip: true, + checkVary: true, + }, + { + name: "health endpoint without gzip support", + path: "/health", + acceptEncoding: "", + setupStorage: func() *FakeSecretMsgStorer { return &FakeSecretMsgStorer{} }, + expectedStatus: http.StatusOK, + expectGzip: false, + checkVary: false, + }, + { + name: "API JSON response with gzip support", + path: "/secret?token=" + validToken, + acceptEncoding: "gzip", + setupStorage: func() *FakeSecretMsgStorer { + return &FakeSecretMsgStorer{ + token: validToken, + msg: "This is a secret message that is long enough to benefit from gzip compression", + } + }, + expectedStatus: http.StatusOK, + expectGzip: true, + checkVary: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cnf := conf{ + HttpBindingAddress: ":8080", + VaultPrefix: "cubbyhole/", + AllowedOrigins: []string{"*"}, + } + storage := tt.setupStorage() + handlers := NewSecretHandlers(storage) + server := NewServer(cnf, handlers) + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + if tt.acceptEncoding != "" { + req.Header.Set("Accept-Encoding", tt.acceptEncoding) + } + rec := httptest.NewRecorder() + server.handler().ServeHTTP(rec, req) + + assert.Equal(t, tt.expectedStatus, rec.Code) + + if tt.expectGzip { + assert.Equal(t, "gzip", rec.Header().Get("Content-Encoding"), "Response should be gzip compressed") + } else { + assert.Empty(t, rec.Header().Get("Content-Encoding"), "Response should not be compressed") + } + + if tt.checkVary { + varyHeader := rec.Header().Get("Vary") + assert.NotEmpty(t, varyHeader, "Should have Vary header") + // Vary header may contain "Origin" from CORS middleware, just verify it exists + assert.Contains(t, "Origin,Accept-Encoding", varyHeader, "Vary header should be set by middleware") + } + }) + } +} diff --git a/internal/vault.go b/internal/vault.go index 7e2a6f8..71e6964 100644 --- a/internal/vault.go +++ b/internal/vault.go @@ -3,45 +3,48 @@ package internal import ( "fmt" "log" - "time" "github.com/hashicorp/vault/api" ) +// SecretMsgStorer defines the interface for storing and retrieving self-destructing messages. +// Implementations must ensure messages are deleted after first retrieval (one-time access). type SecretMsgStorer interface { + // Store saves a message with the specified TTL and returns a unique retrieval token. Store(string, ttl string) (token string, err error) + // Get retrieves a message by token and deletes it from storage (one-time read). Get(token string) (msg string, err error) } +// vault implements SecretMsgStorer using HashiCorp Vault's cubbyhole backend. +// It manages one-time tokens and automatic token renewal for secure message storage. type vault struct { + // address is the Vault server URL (read from VAULT_ADDR if empty). address string - prefix string - token string + // prefix is the Vault storage path prefix (e.g., "cubbyhole/"). + prefix string + // token is the Vault authentication token (read from VAULT_TOKEN if empty). + token string } -// NewVault creates a vault client to talk with underline vault server -func newVault(address string, prefix string, token string) vault { - v := vault{address, prefix, token} +// NewVault creates a new vault client and starts a background goroutine for token renewal. +// If address or token are empty, they will be read from VAULT_ADDR and VAULT_TOKEN +// environment variables respectively. The prefix determines the Vault storage path. +func NewVault(address string, prefix string, token string) *vault { + v := &vault{address, prefix, token} go v.newVaultClientToRenewToken() return v } +// Store saves a message to Vault with the specified time-to-live (TTL). +// Default TTL is 48 hours if not specified. Maximum TTL is 168 hours (7 days). +// Returns a unique one-time token for retrieving the message. +// The token can be used exactly twice: once to store and once to retrieve. func (v vault) Store(msg string, ttl string) (token string, err error) { // Default TTL if ttl == "" { ttl = "48h" - } else { - // Verify duration - d, err := time.ParseDuration(ttl) - if err != nil { - return "", fmt.Errorf("cannot parse duration %v", err) - } - - // validate duration length - if d > 168*time.Hour || d == 0*time.Hour { - return "", fmt.Errorf("cannot set ttl to infinite or more than 7 days %v", err) - } } t, err := v.createOneTimeToken(ttl) @@ -55,6 +58,9 @@ func (v vault) Store(msg string, ttl string) (token string, err error) { return t, nil } +// createOneTimeToken creates a non-renewable Vault token with exactly 2 uses. +// The token is used once to write the message and once to read it, ensuring +// one-time access. The token automatically expires after the specified TTL. func (v vault) createOneTimeToken(ttl string) (string, error) { c, err := v.newVaultClient() if err != nil { @@ -76,6 +82,9 @@ func (v vault) createOneTimeToken(ttl string) (string, error) { return s.Auth.ClientToken, nil } +// newVaultClient creates a new Vault API client with the configured address and token. +// If the vault address is empty, it defaults to using the VAULT_ADDR environment variable. +// If the vault token is empty, it defaults to using the VAULT_TOKEN environment variable. func (v vault) newVaultClient() (*api.Client, error) { c, err := api.NewClient(api.DefaultConfig()) if err != nil { @@ -98,6 +107,9 @@ func (v vault) newVaultClient() (*api.Client, error) { return c, nil } +// writeMsgToVault writes a message to Vault using the provided one-time token. +// The message is stored at the path: //. +// This consumes the first use of the two-use token. func (v vault) writeMsgToVault(token, msg string) error { c, err := v.newVaultClientWithToken(token) if err != nil { @@ -111,6 +123,9 @@ func (v vault) writeMsgToVault(token, msg string) error { return err } +// Get retrieves and deletes a message from Vault using the provided token. +// This consumes the second (final) use of the two-use token, automatically +// deleting both the message and the token from Vault, ensuring one-time access. func (v vault) Get(token string) (msg string, err error) { c, err := v.newVaultClientWithToken(token) if err != nil { @@ -124,6 +139,8 @@ func (v vault) Get(token string) (msg string, err error) { return r.Data["msg"].(string), nil } +// newVaultClientWithToken creates a Vault client authenticated with a specific token. +// Used for one-time token operations when storing and retrieving messages. func (v vault) newVaultClientWithToken(token string) (*api.Client, error) { c, err := v.newVaultClient() if err != nil { @@ -133,6 +150,9 @@ func (v vault) newVaultClientWithToken(token string) (*api.Client, error) { return c, nil } +// newVaultClientToRenewToken runs in a background goroutine to automatically renew +// the main Vault authentication token before it expires. This ensures continuous +// operation of the service without manual token refresh. func (v vault) newVaultClientToRenewToken() { c, err := v.newVaultClient() if err != nil { diff --git a/internal/vault_test.go b/internal/vault_test.go index ab6bae3..56bddee 100644 --- a/internal/vault_test.go +++ b/internal/vault_test.go @@ -39,7 +39,7 @@ func TestStoreAndGet(t *testing.T) { ln, c := createTestVault(t) defer func() { _ = ln.Close() }() - v := newVault(c.Address(), "secret/test/", c.Token()) + v := NewVault(c.Address(), "secret/test/", c.Token()) secret := "my secret" token, err := v.Store(secret, "") if assert.NoError(t, err) { @@ -53,7 +53,7 @@ func TestMsgCanOnlyBeAccessedOnce(t *testing.T) { ln, c := createTestVault(t) defer func() { _ = ln.Close() }() - v := newVault(c.Address(), "secret/test/", c.Token()) + v := NewVault(c.Address(), "secret/test/", c.Token()) secret := "my secret" token, err := v.Store(secret, "") if assert.NoError(t, err) { @@ -64,3 +64,10 @@ func TestMsgCanOnlyBeAccessedOnce(t *testing.T) { assert.Error(t, err) } } + +func TestStoreWithInvalidAddress(t *testing.T) { + v := NewVault("http://invalid:9999", "secret/", "fake-token") + _, err := v.Store("msg", "1h") + + assert.Error(t, err) +} diff --git a/web/static/application.css b/web/static/application.css index cb405d1..ad0dbe3 100644 --- a/web/static/application.css +++ b/web/static/application.css @@ -141,3 +141,98 @@ div.footer img { background: #4CAF50; cursor: pointer; } + +/* Language selector styling */ +.language-selector { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; +} + +.language-selector select { + padding: 10px 36px 10px 14px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.15); + color: white; + font-size: 14px; + font-family: Montserrat, sans-serif; + font-weight: 500; + cursor: pointer; + backdrop-filter: blur(10px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: all 200ms ease-in-out; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='white' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; +} + +.language-selector select:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); +} + +.language-selector select:focus { + outline: none; + border-color: rgba(255, 255, 255, 0.6); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1); +} + +.language-selector select option { + background: #1a1a2e; + color: white; +} + +/* Custom file input styling */ +.custom-file-input { + margin: 10px 0 20px 0; +} + +.hidden-file-input { + width: 0.1px; + height: 0.1px; + opacity: 0; + overflow: hidden; + position: absolute; + z-index: -1; +} + +.file-label { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; +} + +.file-button { + display: inline-block; + padding: 8px 20px; + background: rgba(255, 255, 255, 0.2); + color: white; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.3); + transition: all 200ms ease-in-out; + backdrop-filter: blur(10px); +} + +.file-button:hover { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); +} + +.file-name { + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + font-style: italic; +} + +.file-name.has-file { + color: white; + font-style: normal; +} diff --git a/web/static/getmsg.html b/web/static/getmsg.html index 2c92e5f..547f58b 100644 --- a/web/static/getmsg.html +++ b/web/static/getmsg.html @@ -1,41 +1,50 @@ - sup3rS3cretMes5age + sup3rS3cretMes5age - + - + - +
+
+ +
-

Secret Message

-

Get your secret one-time read only message

+

Secret Message

+

Get your secret one-time read only message

-

Drag the slider to display the Secret Message

+

Drag the slider to display the Secret Message

@@ -47,7 +56,6 @@

Drag the slider to display the Secret Message

- - + diff --git a/web/static/getmsg.js b/web/static/getmsg.js index 7a30cd8..0c20d25 100644 --- a/web/static/getmsg.js +++ b/web/static/getmsg.js @@ -6,23 +6,31 @@ * with automatic base64 decoding. All event handlers are CSP-compliant. */ -// Initialize clipboard functionality +import { $, setupLanguage } from './utils.js'; + +// Initialize clipboard and language manager on DOMContentLoaded document.addEventListener('DOMContentLoaded', function() { + // Initialize clipboard functionality new ClipboardJS('.btn'); + + // Initialize language manager + setupLanguage(); }); -// slider.oninput +// Slider input handler document.getElementById("myRange").addEventListener('input', function() { if (this.value === '100') { // slider.value returns string showSecret(); } }); -document.querySelector('.encrypt[name="newMsg"]').addEventListener('click', function() { +// New message button handler +$('.encrypt[name="newMsg"]').addEventListener('click', function() { // Use relative path to avoid open redirect warnings window.location.href = '/'; }); +// Validate and construct secret URL from token function validateSecretUrl(token) { // Validate token format if (!token || typeof token !== 'string' || !/^[A-Za-z0-9_\-\.]+$/.test(token)) { @@ -37,6 +45,7 @@ function validateSecretUrl(token) { return url.toString(); } +// Fetch and display the secret message function showSecret() { const params = (new URL(window.location)).searchParams; @@ -64,6 +73,7 @@ function showSecret() { }); }; +// Display the secret message and handle file download if applicable function showMsg(msg, filetoken, filename) { // Hide progress bar if it exists const pbar = $('#pbar'); @@ -103,6 +113,7 @@ function showMsg(msg, filetoken, filename) { document.getElementById("myRange").value = 0; } +// Fetch the secret file and trigger download function getSecret(token, name) { const urlStr = validateSecretUrl(token); if (!urlStr) { @@ -134,6 +145,7 @@ var saveData = (function () { }; }()); +// Convert base64 string to Blob function b64toBlob(b64Data, contentType, sliceSize) { sliceSize = sliceSize || 512; diff --git a/web/static/icons/browserconfig.xml b/web/static/icons/browserconfig.xml index b3930d0..c951beb 100644 --- a/web/static/icons/browserconfig.xml +++ b/web/static/icons/browserconfig.xml @@ -2,7 +2,7 @@ - + #da532c diff --git a/web/static/icons/manifest.json b/web/static/icons/manifest.json index 4fbe181..3b9cc6f 100644 --- a/web/static/icons/manifest.json +++ b/web/static/icons/manifest.json @@ -2,12 +2,12 @@ "name": "", "icons": [ { - "src": "/android-chrome-192x192.png", + "src": "/static/icons/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { - "src": "/android-chrome-512x512.png", + "src": "/static/icons/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } @@ -15,4 +15,4 @@ "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" -} \ No newline at end of file +} diff --git a/web/static/index.html b/web/static/index.html index 4ba4a3d..83d12fb 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -1,48 +1,62 @@ - sup3rS3cretMes5age + sup3rS3cretMes5age - + - + - +
-

Secret Message

-

Send a secret one-time read only message

+
+ +
+

Secret Message

+

Send a secret one-time read only message

- Upload Secret File:
- + Upload Secret File: +
+ + +
+
- Time to expire: + Time to expire:
- +
@@ -51,7 +65,7 @@

Secret Message

- +
@@ -64,7 +78,6 @@

Secret Message

- - + diff --git a/web/static/index.js b/web/static/index.js index 1dcd9c5..1876f29 100644 --- a/web/static/index.js +++ b/web/static/index.js @@ -6,6 +6,8 @@ * All event handlers are CSP-compliant. */ +import { $, setupLanguage } from './utils.js'; + // CSS manipulation helper function setStyles(element, styles) { Object.assign(element.style, styles); @@ -15,6 +17,25 @@ function setStyles(element, styles) { document.addEventListener('DOMContentLoaded', function() { // Initialize clipboard functionality new ClipboardJS('.btn'); + + // Initialize language manager + setupLanguage(); + + // Custom file input handler + const fileInput = document.getElementById('file-input'); + const fileNameSpan = $('.file-name'); + if (fileInput && fileNameSpan) { + fileInput.addEventListener('change', function() { + if (this.files && this.files.length > 0) { + fileNameSpan.textContent = this.files[0].name; + fileNameSpan.classList.add('has-file'); + } else { + fileNameSpan.textContent = window.langManager?.translate('no_file_chosen') || 'No file chosen'; + fileNameSpan.classList.remove('has-file'); + } + }); + } + const form = $("#secretform"); form.addEventListener('submit', function(e) { diff --git a/web/static/locales/de.json b/web/static/locales/de.json new file mode 100644 index 0000000..82c7ec1 --- /dev/null +++ b/web/static/locales/de.json @@ -0,0 +1,24 @@ +{ + "title": "sup3rS3cretMes5age", + "secret_message": "Geheimnachricht", + "send_secret_message": "Senden Sie eine geheime Einmal-Nachricht", + "paste_message": "Fügen Sie Ihre Nachricht hier ein", + "upload_file": "Geheime Datei hochladen:", + "choose_file": "Datei auswählen", + "no_file_chosen": "Keine Datei ausgewählt", + "time_to_expire": "Ablaufzeit:", + "submit_button": "Senden", + "copy_to_clipboard": "In die Zwischenablage kopieren", + "get_secret_message": "Holen Sie sich Ihre geheime Einmal-Nachricht", + "drag_slider": "Ziehen Sie den Schieberegler, um die Geheimnachricht anzuzeigen", + "copy_button": "In die Zwischenablage kopieren", + "new_message_button": "Eine geheime Nachricht senden", + "24h": "24h", + "48h": "48h", + "week": "Woche", + "success_message": "Ihre Nachricht wurde sicher gesendet!", + "success_copy": "Klicken Sie auf die Schaltfläche unten, um den Link in Ihre Zwischenablage zu kopieren", + "footer_text": "Unterstützt von sup3rS3cretMes5age", + "meta_description": "Senden Sie sicher zerstörende Einmalnachrichten. Nachrichten werden automatisch nach dem ersten Lesen gelöscht.", + "meta_title": "Selbstzerstörende sichere Nachricht" +} diff --git a/web/static/locales/en.json b/web/static/locales/en.json new file mode 100644 index 0000000..05c212c --- /dev/null +++ b/web/static/locales/en.json @@ -0,0 +1,24 @@ +{ + "title": "sup3rS3cretMes5age", + "secret_message": "Secret Message", + "send_secret_message": "Send a secret one-time read only message", + "paste_message": "Paste your message here", + "upload_file": "Upload Secret File:", + "choose_file": "Choose File", + "no_file_chosen": "No file chosen", + "time_to_expire": "Time to expire:", + "submit_button": "Submit", + "copy_to_clipboard": "Copy to Clipboard", + "get_secret_message": "Get your secret one-time read only message", + "drag_slider": "Drag the slider to display the Secret Message", + "copy_button": "Copy to clipboard", + "new_message_button": "Send a secret message", + "24h": "24h", + "48h": "48h", + "week": "week", + "success_message": "Your message has been securely sent!", + "success_copy": "Click the button below to copy the link to your clipboard", + "footer_text": "Powered by sup3rS3cretMes5age", + "meta_description": "Send self-destructing one-time secret messages securely. Messages are automatically deleted after first read.", + "meta_title": "Self Destructing Secure Message" +} diff --git a/web/static/locales/es.json b/web/static/locales/es.json new file mode 100644 index 0000000..e894aee --- /dev/null +++ b/web/static/locales/es.json @@ -0,0 +1,24 @@ +{ + "title": "sup3rS3cretMes5age", + "secret_message": "Mensaje Secreto", + "send_secret_message": "Enviar un mensaje secreto de uso único", + "paste_message": "Pegue su mensaje aquí", + "upload_file": "Subir archivo secreto:", + "choose_file": "Elegir archivo", + "no_file_chosen": "Ningún archivo seleccionado", + "time_to_expire": "Tiempo de expiración:", + "submit_button": "Enviar", + "copy_to_clipboard": "Copiar al portapapeles", + "get_secret_message": "Obtener su mensaje secreto de uso único", + "drag_slider": "Arrastre el control deslizante para mostrar el mensaje secreto", + "copy_button": "Copiar al portapapeles", + "new_message_button": "Enviar un mensaje secreto", + "24h": "24h", + "48h": "48h", + "week": "semana", + "success_message": "¡Su mensaje ha sido enviado de forma segura!", + "success_copy": "Haga clic en el botón de abajo para copiar el enlace a su portapapeles", + "footer_text": "Desarrollado por sup3rS3cretMes5age", + "meta_description": "Envíe mensajes secretos de uso único que se eliminan automáticamente después de ser leídos.", + "meta_title": "Mensaje Seguro Auto-Destruible" +} diff --git a/web/static/locales/fr.json b/web/static/locales/fr.json new file mode 100644 index 0000000..465cf55 --- /dev/null +++ b/web/static/locales/fr.json @@ -0,0 +1,24 @@ +{ + "title": "sup3rS3cretMes5age", + "secret_message": "Message Secret", + "send_secret_message": "Envoyez un message secret à usage unique", + "paste_message": "Collez votre message ici", + "upload_file": "Télécharger un fichier secret :", + "choose_file": "Choisir un fichier", + "no_file_chosen": "Aucun fichier choisi", + "time_to_expire": "Temps d'expiration :", + "submit_button": "Soumettre", + "copy_to_clipboard": "Copier dans le presse-papier", + "get_secret_message": "Obtenez votre message secret à usage unique", + "drag_slider": "Faites glisser le curseur pour afficher le message secret", + "copy_button": "Copier dans le presse-papier", + "new_message_button": "Envoyer un message secret", + "24h": "24h", + "48h": "48h", + "week": "semaine", + "success_message": "Votre message a été envoyé en toute sécurité !", + "success_copy": "Cliquez sur le bouton ci-dessous pour copier le lien dans votre presse-papier", + "footer_text": "Propulsé par sup3rS3cretMes5age", + "meta_description": "Envoyez des messages secrets à usage unique qui disparaissent automatiquement après lecture.", + "meta_title": "Message Sécurisé Auto-Détruisant" +} diff --git a/web/static/locales/it.json b/web/static/locales/it.json new file mode 100644 index 0000000..65e58df --- /dev/null +++ b/web/static/locales/it.json @@ -0,0 +1,24 @@ +{ + "title": "sup3rS3cretMes5age", + "secret_message": "Messaggio Segreto", + "send_secret_message": "Invia un messaggio segreto di sola lettura", + "paste_message": "Incolla il tuo messaggio qui", + "upload_file": "Carica file segreto:", + "choose_file": "Scegli file", + "no_file_chosen": "Nessun file selezionato", + "time_to_expire": "Tempo di scadenza:", + "submit_button": "Invia", + "copy_to_clipboard": "Copia negli appunti", + "get_secret_message": "Ottieni il tuo messaggio segreto di sola lettura", + "drag_slider": "Trascina lo slider per visualizzare il messaggio segreto", + "copy_button": "Copia negli appunti", + "new_message_button": "Invia un messaggio segreto", + "24h": "24h", + "48h": "48h", + "week": "settimana", + "success_message": "Il tuo messaggio è stato inviato in modo sicuro!", + "success_copy": "Clicca sul pulsante qui sotto per copiare il link negli appunti", + "footer_text": "Offerto da sup3rS3cretMes5age", + "meta_description": "Invia messaggi segreti di sola lettura che si eliminano automaticamente dopo la prima lettura.", + "meta_title": "Messaggio Sicuro Auto-Distruttivo" +} diff --git a/web/static/utils.js b/web/static/utils.js index 5897688..a2ec4c3 100644 --- a/web/static/utils.js +++ b/web/static/utils.js @@ -1,14 +1,174 @@ /** - * DOM Helper Functions - * Provides convenient shortcuts for querySelector and querySelectorAll + * Utility Functions Module + * + * This module provides core utility functions for the sup3rS3cretMes5age application: + * + * DOM Helpers: + * - $() and $$(): jQuery-like selectors for querySelector and querySelectorAll + * + * Internationalization (i18n): + * - detectLanguage(): Auto-detects user language from URL, browser, or defaults to English + * - isValidLanguage(): Validates if a language code is supported (en, fr, de, es, it) + * - loadTranslations(): Fetches and applies translation JSON files dynamically + * - applyTranslations(): Updates DOM elements with data-i18n attributes + * - updateMetaTags(): Updates document title and meta descriptions for SEO + * - switchLanguage(): Changes active language with URL persistence + * + * All functions are exported as ES6 modules and are CSP-compliant. */ // Returns the first element matching the CSS selector -function $(selector) { +export function $(selector) { return document.querySelector(selector); } // Returns all elements matching the CSS selector -function $$(selector) { +export function $$(selector) { return document.querySelectorAll(selector); } + +// Language management functions - simplified and fixed +export function detectLanguage() { + // Check URL parameter first + const urlParams = new URLSearchParams(window.location.search); + const langParam = urlParams.get('lang'); + if (langParam && isValidLanguage(langParam)) { + return langParam; + } + + // Check browser language preference + const browserLang = navigator.language || navigator.userLanguage; + const langCode = browserLang.split('-')[0]; + if (isValidLanguage(langCode)) { + return langCode; + } + + // Default to English + return 'en'; +} + +// Validate if the language is supported +export function isValidLanguage(lang) { + const validLanguages = ['en', 'fr', 'de', 'es', 'it']; + return validLanguages.includes(lang); +} + +// Load translations for the specified language +export async function loadTranslations(language) { + try { + const response = await fetch(`/static/locales/${language}.json`); + const translations = await response.json(); + + // Store translations in a global object + window.translations = translations; + + // Apply translations to current page + applyTranslations(); + + return translations; + } catch (error) { + console.error(`Failed to load translations for ${language}:`, error); + // Fall back to English + return loadTranslations('en'); + } +} + +// Apply translations to the page elements with data-i18n attributes +export function applyTranslations() { + // Translate elements with data-i18n attribute + const elements = $$('[data-i18n]'); + elements.forEach(element => { + const key = element.getAttribute('data-i18n'); + const translation = window.translations?.[key] || key; + + if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { + element.placeholder = translation; + } else { + element.textContent = translation; + } + }); + + // Update meta tags + updateMetaTags(); +} + +// Update meta title and description based on translations +export function updateMetaTags() { + const title = window.translations?.['meta_title'] || 'sup3rS3cretMes5age'; + const description = window.translations?.['meta_description'] || 'Send self-destructing one-time secret messages securely.'; + + // Update standard meta tags + const descMeta = $('meta[name="description"]'); + if (descMeta) { + descMeta.setAttribute('content', description); + } + + const titleElement = $('title'); + if (titleElement) { + titleElement.textContent = title; + } + + // Update Open Graph meta tags + const ogTitle = $('meta[property="og:title"]'); + if (ogTitle) { + ogTitle.setAttribute('content', title); + } + + const ogDescription = $('meta[property="og:description"]'); + if (ogDescription) { + ogDescription.setAttribute('content', description); + } +} + +// Switch language and reload translations +export function switchLanguage(newLanguage) { + if (isValidLanguage(newLanguage)) { + loadTranslations(newLanguage); + + // Update HTML lang attribute for accessibility + document.documentElement.setAttribute('lang', newLanguage); + + // Update language selector value + const languageSelect = document.getElementById('language-select'); + if (languageSelect && languageSelect.value !== newLanguage) { + languageSelect.value = newLanguage; + } + + // Update URL with language parameter + const url = new URL(window.location); + url.searchParams.set('lang', newLanguage); + window.history.pushState({}, '', url); + } +} + +// Setup language on initial load +export function setupLanguage() { + + const currentLanguage = detectLanguage(); + loadTranslations(currentLanguage); + + // Set HTML lang attribute and selector value + document.documentElement.setAttribute('lang', currentLanguage); + + // Set up global language manager + window.langManager = { + currentLanguage: currentLanguage, + switchLanguage: switchLanguage, + translate: function(key) { + return window.translations?.[key] || key; + } + }; + + const languageSelect = document.getElementById('language-select'); + + if (languageSelect) { + // Ensure selector reflects current language + if (languageSelect.value !== currentLanguage) { + languageSelect.value = currentLanguage; + } + // Add event listener for language selector (CSP-compliant) + languageSelect.addEventListener('change', function() { + switchLanguage(this.value); + }); + } +}