diff --git a/.circleci/config.yml b/.circleci/config.yml index 83df9c1..1eb1439 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,6 +4,9 @@ executors: golang: docker: - image: cimg/go:1.26 + node: + docker: + - image: cimg/node:25.8 jobs: lint: @@ -53,10 +56,23 @@ jobs: - save_cache: <<: *save-cache + jslint: + executor: node + steps: + - checkout + - run: + name: Install ESLint + command: npm install eslint + - run: + name: Lint JavaScript + command: | + npx eslint --config eslint.config.js + workflows: lint_test: jobs: - lint + - jslint - test: requires: - lint diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5ff90f8..0b26fed 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,318 +1,241 @@ # sup3rS3cretMes5age Development Instructions -Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here. +Always reference these instructions first and fall back to search or bash commands only when information is missing or inconsistent. ## Working Effectively ### Bootstrap and Dependencies -- 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: +- Install Go 1.26.1+: `go version` must show `go1.26.1` or later. +- Install Docker: required for Vault development server and docker-compose workflows. +- Install Node.js/npm: required for JavaScript linting and Docker web-asset minification stage. +- Install CLI tools for validation: ```bash # Ubuntu/Debian sudo apt-get update && sudo apt-get install -y curl jq - + # Check installations - go version # Must be 1.25.1+ + go version docker --version + node --version + npm --version curl --version jq --version ``` ### 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 v2.7.2` -- takes 30-60 seconds. Current system has v2.7.2. +- Download Go modules: `go mod download` (1-2 minutes). NEVER CANCEL. Set timeout to 180+ seconds. +- Build local binary: `go build -o sup3rs3cret cmd/sup3rS3cretMes5age/main.go`. +- Install Go linter: + ```bash + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \ + sh -s -- -b "$(go env GOPATH)/bin" v2.7.2 + ``` +- Install JavaScript linter (repo uses flat ESLint config): + ```bash + npm install eslint + ``` ### Testing and Validation -- Run tests: `make test` -- takes 2-3 minutes. NEVER CANCEL. Set timeout to 300+ seconds. -- Run linting: `export PATH=$PATH:$(go env GOPATH)/bin && golangci-lint run --timeout 300s` -- takes 30-45 seconds. NEVER CANCEL. Set timeout to 600+ seconds. -- Check formatting: `gofmt -s -l .` -- should return no output if properly formatted -- Run static analysis: `go vet ./...` -- takes <5 seconds - -### Running the Application -**ALWAYS run the bootstrapping steps first before starting the application.** - -#### Start Development Vault Server -```bash -docker run -d --name vault-dev -p 8200:8200 -e VAULT_DEV_ROOT_TOKEN_ID=supersecret hashicorp/vault:latest -``` -Wait 3-5 seconds for Vault to start, then verify: `curl -s http://localhost:8200/v1/sys/health` - -#### Start the Application -```bash -VAULT_ADDR=http://localhost:8200 VAULT_TOKEN=supersecret SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS=":8080" ./sup3rs3cret -``` - -The application will start on port 8080. Access at http://localhost:8080 - -#### Cleanup Development Environment -```bash -docker stop vault-dev && docker rm vault-dev -``` - -### Docker Build and Deployment -The project includes comprehensive Docker support: - -#### Local Development with Docker Compose +- Run unit tests: `make test` (2-3 minutes). NEVER CANCEL. Set timeout to 300+ seconds. +- Run Go linting: + ```bash + export PATH="$PATH:$(go env GOPATH)/bin" + golangci-lint run --timeout 300s + ``` + Takes 30-45 seconds. NEVER CANCEL. Set timeout to 600+ seconds. +- Run JavaScript linting: + ```bash + npx eslint --config eslint.config.js + ``` +- Check formatting: `gofmt -s -l .` (must return no output). +- Run static analysis: `go vet ./...`. + +## Running the Application + +### Local Binary + Vault Dev +1. Start Vault dev server: + ```bash + docker run -d --name vault-dev -p 8200:8200 \ + -e VAULT_DEV_ROOT_TOKEN_ID=supersecret \ + hashicorp/vault:latest + ``` +2. Wait 3-5 seconds and verify: + ```bash + curl -s http://localhost:8200/v1/sys/health + ``` +3. Build and run app: + ```bash + go build -o sup3rs3cret cmd/sup3rS3cretMes5age/main.go + VAULT_ADDR=http://localhost:8200 \ + VAULT_TOKEN=supersecret \ + SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS=":8080" \ + ./sup3rs3cret + ``` +4. Cleanup: + ```bash + docker stop vault-dev && docker rm vault-dev + ``` + +### Docker Compose (Recommended Local Stack) ```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 run # Vault + app (HTTP on :8082) make logs - -# Stop services make stop - -# Clean up make clean ``` - -The default `docker-compose.yml` runs the app on port 8082 (HTTP) with Vault using token `supersecret`. - -#### Production Docker Image +Equivalent direct command: ```bash -# Build multi-platform image with attestations -make image -# Builds for linux/amd64 and linux/arm64 with SBOM and provenance - -# Alternative: Build local image only -docker compose -f deploy/docker-compose.yml build +docker compose -f deploy/docker-compose.yml up --build -d ``` -**Note**: In some CI/containerized environments, Docker builds may encounter certificate verification issues with Go proxy. If this occurs, use local Go builds instead. +## Branch-Specific Behavior (ai-multi-lang) + +### Frontend and i18n +- Frontend uses vanilla JavaScript modules: + - `web/static/utils.js` + - `web/static/index.js` + - `web/static/getmsg.js` +- Supported languages: `en`, `fr`, `es`, `de`, `it`. +- Translation files are in `web/static/locales/*.json` and loaded dynamically. +- Language selection sources: + 1. URL parameter `?lang=xx` + 2. Browser `Accept-Language` + 3. Fallback to English (`en`) +- Language switching is asynchronous and uses request IDs to avoid race conditions. + +### Static Asset Serving and Caching +- Static files are served with cache tiers: + - `/static/fonts/*`: long immutable cache + - `/static/icons/*`: long cache + - `/static/locales/*`: medium cache + - other `/static/*`: short cache +- HTML pages set `Content-Language` and `Vary: Accept-Language`. +- `getmsg` with token uses `Cache-Control: no-store, private`. +- API/health responses include `Vary: Accept-Encoding`; gzip middleware is enabled globally. + +### Security and API Notes +- Rate limiting remains enabled via Echo middleware (10 req/s, burst 20). +- File upload validation includes path traversal checks and 50MB max file size. +- Token validation accepts `hvs.` and `hvb.` formats with strict regex validation. + +### Docker Build Pipeline +- `deploy/Dockerfile` is multi-stage: + - Go builder stage + - Node web-builder stage that minifies JS/HTML/CSS/locale JSON + - Final Alpine runtime image with non-root user +- Image labels include OCI metadata (`version`, `created`, `revision`). ## Validation -### Manual Testing Scenarios -ALWAYS run through these complete end-to-end scenarios after making changes: - -#### Test 1: Basic Message Flow -```bash -# Create secret message -TOKEN=$(curl -X POST -s -F 'msg=test secret message' http://localhost:8080/secret | jq -r .token) - -# Retrieve message (should work once) -curl -s "http://localhost:8080/secret?token=$TOKEN" | jq . - -# Try to retrieve again (should fail - message self-destructs) -curl -s "http://localhost:8080/secret?token=$TOKEN" | jq . -``` - -#### Test 2: CLI Integration -```bash -# Test CLI workflow -echo "test CLI message" | curl -sF 'msg=<-' http://localhost:8080/secret | jq -r .token | awk '{print "http://localhost:8080/getmsg?token="$1}' -``` - -#### Test 3: Health Check -```bash -curl -s http://localhost:8080/health # Should return "OK" -``` +### Manual End-to-End Scenarios +1. Basic message flow: + ```bash + TOKEN=$(curl -X POST -s -F 'msg=test secret message' http://localhost:8080/secret | jq -r .token) + curl -s "http://localhost:8080/secret?token=$TOKEN" | jq . + curl -s "http://localhost:8080/secret?token=$TOKEN" | jq . + ``` +2. CLI-style flow: + ```bash + echo "test CLI message" | curl -sF 'msg=<-' http://localhost:8080/secret | \ + jq -r .token | awk '{print "http://localhost:8080/getmsg?token="$1}' + ``` +3. Health check: + ```bash + curl -s http://localhost:8080/health + ``` +4. Language behavior quick checks: + ```bash + curl -sI 'http://localhost:8080/msg?lang=fr' | grep -i 'Content-Language\|Vary\|Cache-Control' + curl -sI 'http://localhost:8080/getmsg?token=dummy' | grep -i 'Cache-Control\|Content-Language' + ``` ### Pre-commit Validation -Always run these commands before committing: -- `gofmt -s -l .` -- Should return no output -- `go vet ./...` -- Should complete without errors -- `export PATH=$PATH:$(go env GOPATH)/bin && golangci-lint run --timeout 300s` -- Should complete without errors. NEVER CANCEL. Set timeout to 600+ seconds. -- `make test` -- Should pass all tests. NEVER CANCEL. Set timeout to 300+ seconds. - -## 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) -- `SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS`: HTTP port (e.g., `:8080`) -- `SUPERSECRETMESSAGE_HTTPS_BINDING_ADDRESS`: HTTPS port (e.g., `:443`) -- `SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED`: Enable HTTP->HTTPS redirect (`true`/`false`) -- `SUPERSECRETMESSAGE_TLS_AUTO_DOMAIN`: Domain for Let's Encrypt auto-TLS -- `SUPERSECRETMESSAGE_TLS_CERT_FILEPATH`: Manual TLS certificate path -- `SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH`: Manual TLS certificate key path -- `SUPERSECRETMESSAGE_VAULT_PREFIX`: Vault path prefix (default: `cubbyhole/`) - -### Repository Structure +Run all of the following before committing: +- `gofmt -s -l .` +- `go vet ./...` +- `export PATH="$PATH:$(go env GOPATH)/bin" && golangci-lint run --timeout 300s` +- `npx eslint --config eslint.config.js` +- `make test` + +## Configuration Environment Variables +- `VAULT_ADDR`: Vault server address (example: `http://localhost:8200`) +- `VAULT_TOKEN`: Vault authentication token (example: `supersecret` for dev) +- `SUPERSECRETMESSAGE_HTTP_BINDING_ADDRESS`: HTTP bind address (example: `:8080`) +- `SUPERSECRETMESSAGE_HTTPS_BINDING_ADDRESS`: HTTPS bind address (example: `:443`) +- `SUPERSECRETMESSAGE_HTTPS_REDIRECT_ENABLED`: HTTP -> HTTPS redirect (`true`/`false`) +- `SUPERSECRETMESSAGE_TLS_AUTO_DOMAIN`: domain for Let's Encrypt auto TLS +- `SUPERSECRETMESSAGE_TLS_CERT_FILEPATH`: manual TLS cert path +- `SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH`: manual TLS key path +- `SUPERSECRETMESSAGE_VAULT_PREFIX`: Vault secret prefix (default `cubbyhole/`) + +## Repository Structure (Current) ``` . -├── cmd/sup3rS3cretMes5age/ -│ └── main.go # Application entry point (23 lines) -├── internal/ # Core application logic -│ ├── 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 # 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 -```bash -$ ls -la -.circleci/ # CircleCI configuration -.codacy.yml # Code quality config -.dockerignore # Docker ignore patterns -.git/ # Git repository data -.gitignore # Git ignore patterns -CLI.md # Command-line usage guide -CODEOWNERS # GitHub code owners -LICENSE # MIT license -Makefile # Build targets -README.md # Main documentation -cmd/ # Application entry points -deploy/ # Deployment configurations -go.mod # Go module file -go.sum # Go checksum file -internal/ # Internal packages -web/ # Web assets -``` - -#### Package.json Equivalent (go.mod) -```go -module github.com/algolia/sup3rS3cretMes5age - -go 1.25.1 - -require ( - 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.11.1 - golang.org/x/crypto v0.45.0 -) -``` - -### CLI Functions (from CLI.md) -Add to your shell profile for convenient CLI usage: - -```bash -# Basic function for Bash/Zsh -o() { - local url="http://localhost:8080" - local response - - if [ $# -eq 0 ]; then - response=$(curl -sF 'msg=<-' "$url/secret") - else - response=$(cat "$@" | curl -sF 'msg=<-' "$url/secret") - fi - - if [ $? -eq 0 ]; then - echo "$response" | jq -r .token | awk -v url="$url" '{print url"/getmsg?token="$1}' - else - echo "Error: Failed to create secure message" >&2 - return 1 - fi -} -``` - -### Troubleshooting - -**"go: ... tls: failed to verify certificate"** -- 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 -# Ubuntu/Debian -sudo apt-get install jq - -# macOS -brew install jq -``` - -**"vault connection refused"** -- Ensure Vault dev server is running: `docker ps | grep vault` -- Check Vault health: `curl http://localhost:8200/v1/sys/health` -- Restart if needed: `docker restart vault-dev` - -**Test failures with Vault errors** -- 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`). +├── cmd/sup3rS3cretMes5age/main.go +├── internal/ +│ ├── config.go +│ ├── handlers.go +│ ├── server.go +│ ├── vault.go +│ └── *_test.go +├── web/static/ +│ ├── index.html +│ ├── getmsg.html +│ ├── application.css +│ ├── utils.js +│ ├── index.js +│ ├── getmsg.js +│ ├── locales/ +│ │ ├── de.json +│ │ ├── en.json +│ │ ├── es.json +│ │ ├── fr.json +│ │ └── it.json +│ ├── clipboard-2.0.11.min.js +│ ├── fonts/ +│ └── icons/ +├── deploy/ +│ ├── Dockerfile +│ ├── docker-compose.yml +│ └── charts/supersecretmessage/ +├── eslint.config.js +├── Makefile +├── README.md +└── go.mod +``` + +## CI Pipeline (CircleCI) +- `lint` job (Go formatter + golangci-lint, Go image `cimg/go:1.26`) +- `jslint` job (Node image `cimg/node:25.8`, runs ESLint) +- `test` job (`make test`, requires `lint`) ### Helm Deployment -Helm chart located in `deploy/charts/supersecretmessage/`: -- Chart version: 0.1.0 -- App version: 0.2.5 +- Helm chart path: `deploy/charts/supersecretmessage/`. - Includes: Deployment, Service, Ingress, HPA, ServiceAccount - Configurable: Vault connection, TLS settings, resource limits -- See [deploy/charts/README.md](deploy/charts/README.md) for details +- See [deploy/charts/README.md](../deploy/charts/README.md) for details +- Basic install command: + ```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 + ``` +- Typical updates: + - `helm upgrade --install supersecret ./deploy/charts/supersecretmessage ...` + - Adjust ingress, resources, and Vault settings in `values.yaml` or via `--set`. + +## Troubleshooting + +- `go: ... tls: failed to verify certificate` during containerized build: + - Use local Go build: `go build -o sup3rs3cret cmd/sup3rS3cretMes5age/main.go` +- `jq: command not found`: + - Install with `sudo apt-get install jq` (Linux) or `brew install jq` (macOS). +- Vault connection refused: + - `docker ps | grep vault` + - `curl -s http://localhost:8200/v1/sys/health` + - `docker restart vault-dev` +- Port 8082 in use: + - `sudo lsof -i :8082` + - then `make stop` +- If tests emit verbose Vault logs: + - This is expected for integration-style Vault tests; do not cancel test runs. diff --git a/Makefile b/Makefile index 965f8e9..c7bb0df 100644 --- a/Makefile +++ b/Makefile @@ -30,18 +30,21 @@ image: $(DOCKER_OPS) . build: - @docker compose $(COMPOSE_OPTS) build + @docker compose $(COMPOSE_OPTS) build \ + --build-arg VERSION=$(VERSION) \ + --build-arg BUILD_DATE="$(BUILD_DATE)" \ + --build-arg VCS_REF=$(VCS_REF) clean: @docker compose $(COMPOSE_OPTS) rm -fv -run-local: clean +run-local: clean build @DOMAIN=$(DOMAIN) \ - docker compose $(COMPOSE_OPTS) up --build -d + docker compose $(COMPOSE_OPTS) up -d run: @DOMAIN=$(DOMAIN) \ - docker compose $(COMPOSE_OPTS) up --build -d + docker compose $(COMPOSE_OPTS) up -d logs: @docker compose $(COMPOSE_OPTS) logs -f diff --git a/README.md b/README.md index 8f39e61..25adedf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ Read more about the reasoning behind this project in the [relevant blog post](ht - **🔐 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 @@ -40,6 +44,7 @@ Read more about the reasoning behind this project in the [relevant blog post](ht - [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) @@ -49,18 +54,24 @@ Read more about the reasoning behind this project in the [relevant blog post](ht ## 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 | 2.3KB | Application styling | +| Custom CSS | 3.3KB | Application styling (minified) | +| Translation files | ~1KB each | i18n support (loaded on-demand) | ✅ **No external CDNs or tracking** - All dependencies are self-hosted for privacy and security. 📦 **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: @@ -382,15 +393,54 @@ SUPERSECRETMESSAGE_TLS_CERT_FILEPATH=/mnt/ssl/cert_secrets.example.com.pem SUPERSECRETMESSAGE_TLS_CERT_KEY_FILEPATH=/mnt/ssl/key_secrets.example.com.pem ``` +## 🌍 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/0ada574b-99e4-4562-aea4-a1868d6ca0d8) +![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/6d0c455f-00ca-430e-bc8c-e721e071843a") +![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.* @@ -444,25 +494,34 @@ go vet ./... ``` . ├── cmd/sup3rS3cretMes5age/ # Application entry point -│ └── main.go # (23 lines) +│ └── main.go # (67 lines) ├── internal/ # Core business logic -│ ├── config.go # Configuration (77 lines) -│ ├── handlers.go # HTTP handlers (88 lines) -│ ├── server.go # Server setup (94 lines) -│ └── vault.go # Vault integration (174 lines) +│ ├── 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 +│ ├── 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 -│ ├── docker-compose.yml # Local dev stack -│ └── charts/ # Helm chart -└── Makefile # Build automation +│ ├── 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**: 609 lines of Go across 7 files +**Total Code**: 1,043 lines of Go across 4 core files (excluding tests) ## Contributing diff --git a/deploy/Dockerfile b/deploy/Dockerfile index bebc2df..8b5e829 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -3,17 +3,6 @@ FROM golang:1.26 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 . . @@ -26,9 +15,54 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o /tmp/sup3rS3cretMes5age \ cmd/sup3rS3cretMes5age/main.go +# Web assets minification stage +FROM node:25.8.1-alpine AS web-builder +WORKDIR /app +COPY web/ ./ + +# Minify JS and CSS files +RUN npm install -g @node-minify/cli@10 \ + @node-minify/terser@10 \ + @node-minify/lightningcss@10 \ + @node-minify/html-minifier@10 \ + @node-minify/jsonminify@10 && \ + 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 +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" \ + org.opencontainers.image.url="https://github.com/algolia/sup3rS3cretMes5age" \ + org.opencontainers.image.source="https://github.com/algolia/sup3rS3cretMes5age" + + # Install only necessary certificates and packages RUN apk add --no-cache \ ca-certificates \ @@ -45,11 +79,11 @@ WORKDIR /opt/supersecret # Copy binary and static assets COPY --from=builder --chown=supersecret:supersecret /tmp/sup3rS3cretMes5age ./sup3rS3cretMes5age -COPY --chown=supersecret:supersecret web/static/ ./static/ +COPY --from=web-builder --chown=supersecret:supersecret /app/static ./static # Set proper file permissions RUN chmod 755 ./sup3rS3cretMes5age \ - && chmod 644 ./static/* \ + && find ./static -type f -exec chmod 644 {} \; \ && find ./static -type d -exec chmod 755 {} \; # Define environment variables diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..33fd4ea --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,32 @@ + const browserGlobals = { + globals: { + ClipboardJS: "readonly", + navigator: "readonly", + window: "readonly", + document: "readonly", + URLSearchParams: "readonly", + URL: "readonly", + Blob: "readonly", + Uint8Array: "readonly", + atob: "readonly", + fetch: "readonly", + console: "readonly", + FormData: "readonly", + FileReader: "readonly" + } + }; + + export default [ + { + files: ["web/static/**/*.js"], + ignores: ["web/static/clipboard-*js"], + languageOptions: { + sourceType: "module", + globals: { ...browserGlobals.globals } + }, + rules: { + "no-undef": "error", + "no-unused-vars": "warn" + } + } + ]; diff --git a/internal/handlers.go b/internal/handlers.go index 704a7c9..ea4623e 100644 --- a/internal/handlers.go +++ b/internal/handlers.go @@ -7,6 +7,8 @@ import ( "mime" "mime/multipart" "net/http" + "os" + "path/filepath" "regexp" "strings" "time" @@ -185,12 +187,17 @@ func (s SecretHandlers) GetMsgHandler(ctx echo.Context) error { r := &MsgResponse{ Msg: m, } + + h := ctx.Response().Header() + addToVaryHeader(h, "Accept-Encoding") + h.Set("Cache-Control", "no-store") 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 { + addToVaryHeader(ctx.Response().Header(), "Accept-Encoding") return ctx.String(http.StatusOK, http.StatusText(http.StatusOK)) } @@ -198,3 +205,139 @@ func healthHandler(ctx echo.Context) error { 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 +} + +func addToVaryHeader(h http.Header, value string) { + existing := h.Get("Vary") + if existing == "" { + h.Set("Vary", value) + return + } + + for _, v := range strings.Split(existing, ",") { + if strings.TrimSpace(v) == value { + // Value already present, nothing to do. + return + } + } + + h.Set("Vary", existing+", "+value) +} + +// htmlHandler serves HTML files with language preference handling. +func htmlHandler(ctx echo.Context, path string) error { + // Check for language preference in query parameter 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 + h := ctx.Response().Header() + h.Set("Content-Language", lang) + + // Set caching headers: disable storage for getmsg.html with token, public for others + if path == "static/getmsg.html" && ctx.QueryParam("token") != "" { + h.Set("Cache-Control", "no-store, private") + } else { + h.Set("Cache-Control", "public, max-age=300, must-revalidate") + } + + addToVaryHeader(h, "Accept-Encoding") + addToVaryHeader(h, "Accept-Language") + + 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 +} + +// commonCacheHandler serves static files with specified Cache-Control headers. +func commonCacheHandler(ctx echo.Context, cacheControl string) error { + path, err := getCleanedPath(ctx) + if err != nil { + return err + } + + // Check file existence before setting cache headers to avoid caching error responses + if stat, err := os.Stat(path); err != nil || stat.IsDir() { + return echo.NewHTTPError(http.StatusNotFound, "file not found") + } + + h := ctx.Response().Header() + + if strings.HasSuffix(path, ".js") { + h.Set("Content-Type", "application/javascript; charset=utf-8") + } else if strings.HasSuffix(path, ".css") { + h.Set("Content-Type", "text/css; charset=utf-8") + } else if strings.HasSuffix(path, ".json") { + h.Set("Content-Type", "application/json") + } + + h.Set("Cache-Control", cacheControl) + addToVaryHeader(h, "Accept-Encoding") + return ctx.File(path) +} + +// shortCacheHandler serves static files with short-term (5 minutes) caching headers. +func shortCacheHandler(ctx echo.Context) error { + return commonCacheHandler(ctx, "public, max-age=300, must-revalidate") +} + +// mediumCacheHandler serves static files with medium-term (1 hour) caching headers. +func mediumCacheHandler(ctx echo.Context) error { + return commonCacheHandler(ctx, "public, max-age=3600, must-revalidate") +} + +// longCacheHandler serves static files with long-term (24 hours) caching headers. +func longCacheHandler(ctx echo.Context) error { + return commonCacheHandler(ctx, "public, max-age=86400, must-revalidate") +} + +// fontCacheHandler serves font files with long-term immutable caching. +func fontCacheHandler(ctx echo.Context) error { + return commonCacheHandler(ctx, "public, max-age=2592000, immutable") +} diff --git a/internal/server.go b/internal/server.go index 23ed488..ef85a58 100644 --- a/internal/server.go +++ b/internal/server.go @@ -210,12 +210,15 @@ func setupMiddlewares(e *echo.Echo, cnf conf) { MaxAge: 86400, })) - // Limit to 5 RPS (burst 10) (only human should use this service) + // Enable Gzip compression for all responses + e.Use(middleware.Gzip()) + + // Limit to 10 RPS (burst 20) (only human should use this service) e.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ Store: middleware.NewRateLimiterMemoryStoreWithConfig( middleware.RateLimiterMemoryStoreConfig{ - Rate: 5, - Burst: 10, + Rate: 10, + Burst: 20, ExpiresIn: 1 * time.Minute, }, ), @@ -260,12 +263,19 @@ func setupRoutes(e *echo.Echo, handlers *SecretHandlers) { e.Any("/health", healthHandler) + // API secret endpoints 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") + // 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 index a3f4727..efe672e 100644 --- a/internal/server_test.go +++ b/internal/server_test.go @@ -200,7 +200,7 @@ func TestServerRateLimiting(t *testing.T) { successCount := 0 rateLimitCount := 0 - for i := 0; i < 20; i++ { + 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() @@ -217,3 +217,85 @@ func TestServerRateLimiting(t *testing.T) { // 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, varyHeader, "Accept-Encoding", "Vary header should be set by middleware") + } + }) + } +} 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..e546260 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..2e98bc9 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) { @@ -125,7 +136,7 @@ var saveData = (function () { document.body.appendChild(a); a.style.display = "none"; return function (data, fileName) { - const blob = b64toBlob([data], { type: "octet/stream" }) + const blob = b64toBlob([data], "application/octet-stream") const url = window.URL.createObjectURL(blob); a.href = url; a.download = fileName; @@ -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..14f744b 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..914be26 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,30 @@ 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'); + const originalFileNameI18nKey = fileNameSpan ? fileNameSpan.getAttribute('data-i18n') : null; + 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'); + fileNameSpan.removeAttribute('data-i18n'); + } else { + fileNameSpan.textContent = window.langManager?.translate('no_file_chosen') || 'No file chosen'; + fileNameSpan.classList.remove('has-file'); + if (originalFileNameI18nKey) { + fileNameSpan.setAttribute('data-i18n', originalFileNameI18nKey); + } + } + }); + } + const form = $("#secretform"); form.addEventListener('submit', function(e) { @@ -64,7 +90,7 @@ document.addEventListener('DOMContentLoaded', function() { }) .catch(error => { console.error(`An error occurred: ${error}`); - alert('An error occurred while creating the secret message.'); + window.alert('An error occurred while creating the secret message.'); }); }); }); 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..1d02803 100644 --- a/web/static/utils.js +++ b/web/static/utils.js @@ -1,14 +1,218 @@ /** - * 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 +// Request ID counter to prevent race conditions in language switching +let translationRequestId = 0; + +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 +// requestId guards against race conditions from rapid language switching +export async function loadTranslations(language, requestId = null) { + try { + const response = await fetch(`/static/locales/${language}.json`); + if (!response.ok) { + throw new Error(`HTTP error ${response.status} while loading /static/locales/${language}.json`); + } + + const translations = await response.json(); + + // Guard against stale requests: only apply if this is the latest request + if (requestId !== null && requestId !== translationRequestId) { + // Discarding stale translation request + return null; + } + + // 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); + // If English (fallback) also fails, avoid infinite recursion. + if (language === 'en') { + if (!window.translations) { + window.translations = {}; + } + applyTranslations(); + return window.translations; + } + // Fall back to English + return loadTranslations('en', requestId); + } +} + +// 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 if (element.tagName === 'META') { + element.setAttribute('content', 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 +// async to properly await translation loading and prevent race conditions +export async function switchLanguage(newLanguage) { + if (!isValidLanguage(newLanguage)) { + return; + } + + // Increment request ID to invalidate any in-flight requests + const currentRequestId = ++translationRequestId; + + // 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); + + // Load translations with request ID to guard against race conditions + const result = await loadTranslations(newLanguage, currentRequestId); + + // Only update currentLanguage if the request wasn't superseded + if (result !== null && window.langManager) { + window.langManager.currentLanguage = newLanguage; + } +} + +// Setup language on initial load +export async function setupLanguage() { + + const currentLanguage = detectLanguage(); + + // Increment request ID and use it for the initial load to avoid races + const currentRequestId = ++translationRequestId; + const result = await loadTranslations(currentLanguage, currentRequestId); + + // If a newer language request was made while we were loading, abort + if (currentRequestId !== translationRequestId || result === null) { + return; + } + + // 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); + }); + } +}