Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 74 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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.*

Expand Down Expand Up @@ -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

Expand Down
30 changes: 29 additions & 1 deletion deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,34 @@ RUN CGO_ENABLED=0 GOOS=linux go build \
-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

Expand All @@ -45,7 +73,7 @@ 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 \
Expand Down
119 changes: 119 additions & 0 deletions internal/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"mime"
"mime/multipart"
"net/http"
"path/filepath"
"regexp"
"strings"
"time"
Expand Down Expand Up @@ -198,3 +199,121 @@ 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
}

// 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)
}
24 changes: 17 additions & 7 deletions internal/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,15 @@ func setupMiddlewares(e *echo.Echo, cnf conf) {
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: 5,
Burst: 10,
Rate: 10,
Burst: 20,
ExpiresIn: 1 * time.Minute,
},
),
Expand Down Expand Up @@ -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)
}
Loading