mirror of
https://github.com/coollabsio/coollabs-cdn.git
synced 2026-06-19 07:35:28 +00:00
taco
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.gitignore
|
||||
CLAUDE.md
|
||||
LICENSE
|
||||
README.md
|
||||
test.sh
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Binaries for programs and plugins
|
||||
coollabs-cdn
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
.DS_Store
|
||||
@@ -0,0 +1,108 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
coolLabs CDN is a lightweight Go-based CDN server that serves static JSON files and images with HTTP caching support. It's designed to be deployed as a minimal Docker container (scratch-based, ~10MB) with multi-architecture support (AMD64/ARM64).
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Embedded File System
|
||||
- All JSON files in the `json/` directory and images in the `images/` directory are embedded into the Go binary at build time using `go:embed`
|
||||
- The `loadJSONFiles()` and `loadImageFiles()` functions recursively walk the embedded filesystem on startup
|
||||
- Files are loaded into memory maps (`files` and `etags`) with pre-calculated MD5 ETags
|
||||
- URL paths mirror the directory structure (e.g., `json/api/v1/data.json` → `/api/v1/data.json`, `images/logo.png` → `/logo.png`)
|
||||
|
||||
### Request Handling Flow
|
||||
The main request handler (`handleRequest()`) processes requests in this order:
|
||||
1. Sets CORS headers for all requests
|
||||
2. Handles OPTIONS preflight requests (returns 204)
|
||||
3. Root path (`/`) → redirects to `https://{BASE_FQDN}`
|
||||
4. Health check (`/health`) → returns "healthy" with 200
|
||||
5. Known files → serves JSON or images with appropriate Content-Type and ETag caching
|
||||
6. Unknown files (404) → redirects to `https://{BASE_FQDN}{RequestURI}`
|
||||
|
||||
### Caching Strategy
|
||||
- MD5-based ETags are calculated once at startup for all files
|
||||
- Client sends `If-None-Match` header with ETag
|
||||
- Server returns 304 Not Modified if ETag matches
|
||||
- Also sets `Last-Modified` and supports Range requests via `http.ServeContent()`
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Local build
|
||||
go build -o coollabs-cdn main.go
|
||||
|
||||
# Docker build (multi-arch)
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t coollabs-cdn .
|
||||
|
||||
# Docker build with custom redirect domain
|
||||
docker buildx build --platform linux/amd64,linux/arm64 --build-arg BASE_FQDN=yourdomain.com -t coollabs-cdn .
|
||||
```
|
||||
|
||||
### Running
|
||||
```bash
|
||||
# Run locally (requires json/ and images/ directories)
|
||||
go run main.go
|
||||
|
||||
# Run with custom BASE_FQDN
|
||||
BASE_FQDN=example.com go run main.go
|
||||
|
||||
# Run Docker container
|
||||
docker run -p 8080:80 coollabs-cdn
|
||||
|
||||
# Run with runtime BASE_FQDN override
|
||||
docker run -e BASE_FQDN=mysite.com -p 8080:80 coollabs-cdn
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run full test suite against localhost:8080
|
||||
./test.sh
|
||||
|
||||
# Test against custom port
|
||||
./test.sh 8081
|
||||
|
||||
# Test against custom host and port
|
||||
./test.sh 8081 example.com
|
||||
```
|
||||
|
||||
The test script (`test.sh`) verifies all functionality including health checks, JSON and image serving, ETag caching, CORS, redirects, and HTTP headers.
|
||||
|
||||
## Configuration
|
||||
|
||||
### BASE_FQDN
|
||||
The only configurable parameter controls where root and 404 requests redirect:
|
||||
- **Build-time**: `--build-arg BASE_FQDN=domain.com` (default: `coollabs.io`)
|
||||
- **Runtime**: `-e BASE_FQDN=domain.com` environment variable
|
||||
- Used in `main.go:62-65` and `main.go:102,118`
|
||||
|
||||
## Adding New Files
|
||||
|
||||
### JSON Files
|
||||
1. Place files in `json/` directory (supports nested subdirectories)
|
||||
2. Rebuild the Docker image (files are embedded at build time)
|
||||
3. Files automatically become available at their path (e.g., `json/data.json` → `/data.json`)
|
||||
|
||||
### Image Files
|
||||
1. Place image files in `images/` directory (supports nested subdirectories)
|
||||
2. Supported formats: PNG, JPEG, GIF, WEBP, SVG, BMP, ICO
|
||||
3. Rebuild the Docker image (files are embedded at build time)
|
||||
4. Files automatically become available at their path (e.g., `images/logo.png` → `/logo.png`)
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Why Scratch Base Image
|
||||
Uses `FROM scratch` for minimal attack surface and size. No shell, no package manager, just the statically-compiled binary and embedded files.
|
||||
|
||||
### Why Embed vs Runtime Loading
|
||||
Files are embedded using `//go:embed json/*` and `//go:embed images/*` because:
|
||||
- Scratch containers have no filesystem to mount
|
||||
- Faster startup (no disk I/O)
|
||||
- Immutable deployments (files can't change at runtime)
|
||||
|
||||
### CORS Configuration
|
||||
All responses include `Access-Control-Allow-Origin: *` to allow cross-origin requests from any domain. Handles OPTIONS preflight requests for complex CORS scenarios.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod file (go.sum may not exist for projects with no external deps)
|
||||
COPY go.mod* go.sum* ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY main.go healthcheck.go ./
|
||||
COPY json/ ./json/
|
||||
COPY images/ ./images/
|
||||
|
||||
# Build the binaries with optimizations for the target platform
|
||||
ARG TARGETOS TARGETARCH BASE_FQDN=coollabs.io
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -a -installsuffix cgo -o coollabs-cdn main.go
|
||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -a -installsuffix cgo -o healthcheck healthcheck.go
|
||||
|
||||
# Final stage
|
||||
FROM scratch
|
||||
|
||||
# Copy the binaries from builder stage
|
||||
COPY --from=builder /app/coollabs-cdn /coollabs-cdn
|
||||
COPY --from=builder /app/healthcheck /healthcheck
|
||||
|
||||
# Set the base FQDN environment variable
|
||||
ARG BASE_FQDN=coollabs.io
|
||||
ENV BASE_FQDN=$BASE_FQDN
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Add healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/healthcheck"]
|
||||
|
||||
# Run the binary
|
||||
CMD ["/coollabs-cdn"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 coolLabs Solutions Kft.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,123 @@
|
||||
# coolLabs CDN
|
||||
|
||||
A lightweight Go-based CDN for serving static JSON files with ETag support.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── Dockerfile
|
||||
├── main.go
|
||||
├── go.mod
|
||||
├── json/ # Place your JSON files here (supports subdirectories)
|
||||
│ ├── file.json # Served at /file.json
|
||||
│ └── subdir/
|
||||
│ └── data.json # Served at /subdir/data.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Recursive Directory Support**: Serves JSON files from nested subdirectories
|
||||
- **Dynamic File Loading**: Automatically discovers and serves all `*.json` files
|
||||
- **Configurable Redirects**: Customizable base domain for redirects via `BASE_FQDN`
|
||||
- **ETag Support**: MD5-based ETag generation for efficient cache validation
|
||||
- **CORS Enabled**: Full cross-origin request support with preflight handling
|
||||
- **JSON MIME Types**: Proper Content-Type headers for JSON files
|
||||
- **HTTP Caching**: Last-Modified headers and 304 Not Modified responses
|
||||
- **Range Requests**: Support for partial content requests (Accept-Ranges: bytes)
|
||||
- **Scratch-based**: Ultra-small image size (~10MB)
|
||||
- **Multi-arch**: Compatible with AMD64 and ARM64 architectures
|
||||
- **Health Check Endpoint**: Available at `/health`
|
||||
- **Embedded Files**: JSON files embedded in binary for instant startup
|
||||
- **HEAD Method**: Full support for HEAD requests
|
||||
|
||||
## Usage
|
||||
|
||||
1. Add your JSON files to the `json/` directory (supports nested directories):
|
||||
```bash
|
||||
# Root level files
|
||||
echo '{"message": "Hello World"}' > json/example.json
|
||||
echo '{"version": "1.0.0", "data": []}' > json/config.json
|
||||
|
||||
# Nested directories
|
||||
mkdir -p json/api/v1
|
||||
echo '{"endpoints": ["/users", "/posts"]}' > json/api/v1/routes.json
|
||||
echo '{"database": "connected"}' > json/api/v1/health.json
|
||||
```
|
||||
|
||||
2. Build the Docker image:
|
||||
```bash
|
||||
docker buildx build --platform linux/amd64,linux/arm64 --build-arg BASE_FQDN=yourdomain.com -t coollabs-cdn .
|
||||
```
|
||||
|
||||
3. Run the container:
|
||||
```bash
|
||||
docker run -p 8080:80 coollabs-cdn # (built with --build-arg BASE_FQDN=yourdomain.com)
|
||||
```
|
||||
|
||||
4. Test the implementation:
|
||||
```bash
|
||||
# Run tests
|
||||
./test.sh 8080
|
||||
|
||||
# Or test manually:
|
||||
# Health check
|
||||
curl http://localhost:8080/health
|
||||
|
||||
# Access any JSON file (including nested paths)
|
||||
curl http://localhost:8080/example.json
|
||||
curl http://localhost:8080/config.json
|
||||
curl http://localhost:8080/api/v1/routes.json
|
||||
curl http://localhost:8080/status/health.json
|
||||
|
||||
# ETag caching works for all files
|
||||
curl -i http://localhost:8080/example.json
|
||||
curl -i -H 'If-None-Match: "ETAG_VALUE"' http://localhost:8080/example.json
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### BASE_FQDN Environment Variable
|
||||
|
||||
The `BASE_FQDN` environment variable controls the domain used for redirects (root path and 404 errors). It defaults to `coollabs.io` if not set.
|
||||
|
||||
**Options:**
|
||||
- **Environment Variable**: Set `BASE_FQDN=yourdomain.com` when running the container
|
||||
- **Build Argument**: Set `--build-arg BASE_FQDN=yourdomain.com` when building the image
|
||||
- **Default**: `coollabs.io`
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Runtime configuration
|
||||
docker run -e BASE_FQDN=mysite.com -p 8080:80 coollabs-cdn
|
||||
|
||||
# Build-time configuration
|
||||
docker build --build-arg BASE_FQDN=mysite.com -t coollabs-cdn .
|
||||
docker run -p 8080:80 coollabs-cdn # Uses mysite.com for redirects
|
||||
```
|
||||
|
||||
## Health Check
|
||||
The service provides a health check endpoint:
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
```
|
||||
|
||||
## Testing
|
||||
Run the test suite to verify all functionality:
|
||||
```bash
|
||||
# Test against running container on port 8080
|
||||
./test.sh
|
||||
|
||||
# Test against different port/host
|
||||
./test.sh 8081 myhost.com
|
||||
```
|
||||
|
||||
The test script verifies:
|
||||
- Health endpoint functionality
|
||||
- JSON file serving with proper headers
|
||||
- ETag caching (304 responses)
|
||||
- CORS headers and preflight requests
|
||||
- Root and 404 redirects
|
||||
- Content-Type headers
|
||||
- Cache-Control headers
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
resp, err := http.Get("http://localhost:80/health")
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"community": 20000,
|
||||
"mrr": 15000,
|
||||
"gh-sponsors": 4500,
|
||||
"open-collective": 1200
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0-beta.460"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0-beta.461"
|
||||
},
|
||||
"helper": {
|
||||
"version": "1.0.12"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.10"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.18"
|
||||
}
|
||||
},
|
||||
"traefik": {
|
||||
"v3.6": "3.6.5",
|
||||
"v3.5": "3.5.6",
|
||||
"v3.4": "3.4.5",
|
||||
"v3.3": "3.3.7",
|
||||
"v3.2": "3.2.5",
|
||||
"v3.1": "3.1.7",
|
||||
"v3.0": "3.0.4",
|
||||
"v2.11": "2.11.32"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed json/*
|
||||
var jsonFiles embed.FS
|
||||
|
||||
//go:embed images/*
|
||||
var imageFiles embed.FS
|
||||
|
||||
// loadJSONFiles recursively loads all JSON files from the embedded filesystem
|
||||
func loadJSONFiles(dir, prefix string, files map[string]*fileData, etags map[string]string) error {
|
||||
entries, err := jsonFiles.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fullPath := dir + "/" + entry.Name()
|
||||
|
||||
if entry.IsDir() {
|
||||
// Recursively process subdirectories
|
||||
newPrefix := prefix + "/" + entry.Name()
|
||||
if err := loadJSONFiles(fullPath, newPrefix, files, etags); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if strings.HasSuffix(entry.Name(), ".json") {
|
||||
// Load JSON file
|
||||
content, err := jsonFiles.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read embedded file %s: %v", fullPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create URL path (remove "json" prefix and add leading slash)
|
||||
urlPath := prefix + "/" + entry.Name()
|
||||
|
||||
files[urlPath] = &fileData{
|
||||
content: content,
|
||||
modTime: time.Now(), // Use build time as mod time
|
||||
}
|
||||
|
||||
// Calculate ETag
|
||||
hash := md5.Sum(content)
|
||||
etags[urlPath] = fmt.Sprintf("\"%x\"", hash)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadImageFiles recursively loads all image files from the embedded filesystem
|
||||
func loadImageFiles(dir, prefix string, files map[string]*fileData, etags map[string]string) error {
|
||||
entries, err := imageFiles.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fullPath := dir + "/" + entry.Name()
|
||||
|
||||
if entry.IsDir() {
|
||||
// Recursively process subdirectories
|
||||
newPrefix := prefix + "/" + entry.Name()
|
||||
if err := loadImageFiles(fullPath, newPrefix, files, etags); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if isImageFile(entry.Name()) {
|
||||
// Load image file
|
||||
content, err := imageFiles.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read embedded file %s: %v", fullPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create URL path (remove "images" prefix and add leading slash)
|
||||
urlPath := prefix + "/" + entry.Name()
|
||||
|
||||
files[urlPath] = &fileData{
|
||||
content: content,
|
||||
modTime: time.Now(), // Use build time as mod time
|
||||
}
|
||||
|
||||
// Calculate ETag
|
||||
hash := md5.Sum(content)
|
||||
etags[urlPath] = fmt.Sprintf("\"%x\"", hash)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isImageFile checks if a file has a supported image extension
|
||||
func isImageFile(filename string) bool {
|
||||
extensions := []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico"}
|
||||
for _, ext := range extensions {
|
||||
if strings.HasSuffix(strings.ToLower(filename), ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getContentType returns the appropriate Content-Type header for a file path
|
||||
func getContentType(path string) string {
|
||||
lowerPath := strings.ToLower(path)
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(lowerPath, ".json"):
|
||||
return "application/json"
|
||||
case strings.HasSuffix(lowerPath, ".png"):
|
||||
return "image/png"
|
||||
case strings.HasSuffix(lowerPath, ".jpg"), strings.HasSuffix(lowerPath, ".jpeg"):
|
||||
return "image/jpeg"
|
||||
case strings.HasSuffix(lowerPath, ".gif"):
|
||||
return "image/gif"
|
||||
case strings.HasSuffix(lowerPath, ".webp"):
|
||||
return "image/webp"
|
||||
case strings.HasSuffix(lowerPath, ".svg"):
|
||||
return "image/svg+xml"
|
||||
case strings.HasSuffix(lowerPath, ".bmp"):
|
||||
return "image/bmp"
|
||||
case strings.HasSuffix(lowerPath, ".ico"):
|
||||
return "image/x-icon"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Read base FQDN from environment variable, default to coollabs.io
|
||||
baseFQDN := os.Getenv("BASE_FQDN")
|
||||
if baseFQDN == "" {
|
||||
baseFQDN = "coollabs.io"
|
||||
}
|
||||
|
||||
// Create a map of embedded files with metadata
|
||||
files := make(map[string]*fileData)
|
||||
etags := make(map[string]string)
|
||||
|
||||
// Recursively load all JSON files from embedded json directory
|
||||
err := loadJSONFiles("json", "", files, etags)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load JSON files:", err)
|
||||
}
|
||||
|
||||
// Recursively load all image files from embedded images directory
|
||||
err = loadImageFiles("images", "", files, etags)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load image files:", err)
|
||||
}
|
||||
|
||||
log.Printf("Loaded %d files total: %v", len(files), getFileList(files))
|
||||
log.Printf("Base FQDN for redirects: %s", baseFQDN)
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRequest(w, r, baseFQDN, files, etags)
|
||||
})
|
||||
|
||||
log.Println("Starting server on :80")
|
||||
log.Fatal(http.ListenAndServe(":80", nil))
|
||||
}
|
||||
|
||||
func handleRequest(w http.ResponseWriter, r *http.Request, baseFQDN string, files map[string]*fileData, etags map[string]string) {
|
||||
// Set CORS headers
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept")
|
||||
|
||||
// Handle OPTIONS requests for CORS preflight
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle root path redirect
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "https://"+baseFQDN, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle health check
|
||||
if r.URL.Path == "/health" {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("healthy\n"))
|
||||
return
|
||||
}
|
||||
|
||||
// Backward compatibility: serve files at old /coolify/ paths
|
||||
switch r.URL.Path {
|
||||
case "/coolify/versions.json":
|
||||
r.URL.Path = "/versions.json"
|
||||
case "/coolify/upgrade.sh":
|
||||
r.URL.Path = "/upgrade.sh"
|
||||
}
|
||||
|
||||
// Redirect /coolify/install.sh to cdn.coolify.io
|
||||
if r.URL.Path == "/coolify/install.sh" {
|
||||
http.Redirect(w, r, "https://cdn.coolify.io/install.sh", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
fileData, exists := files[r.URL.Path]
|
||||
if !exists {
|
||||
// 404 redirect to base FQDN (without path)
|
||||
http.Redirect(w, r, "https://"+baseFQDN, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Set content type based on file extension
|
||||
contentType := getContentType(r.URL.Path)
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
w.Header().Set("Cache-Control", "public, must-revalidate, max-age=600")
|
||||
|
||||
// Handle ETag caching manually for embedded files
|
||||
etag := etags[r.URL.Path]
|
||||
w.Header().Set("ETag", etag)
|
||||
|
||||
// Check If-None-Match header
|
||||
if match := r.Header.Get("If-None-Match"); match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
// Include ETag in 304 response as per HTTP spec
|
||||
w.Header().Set("ETag", etag)
|
||||
return
|
||||
}
|
||||
|
||||
// Use http.ServeContent for range request support and Last-Modified handling
|
||||
reader := bytes.NewReader(fileData.content)
|
||||
http.ServeContent(w, r, filepath.Base(r.URL.Path), fileData.modTime, reader)
|
||||
}
|
||||
|
||||
type fileData struct {
|
||||
content []byte
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func getFileList(files map[string]*fileData) []string {
|
||||
var names []string
|
||||
for path := range files {
|
||||
names = append(names, path)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test script for coolLabs CDN
|
||||
# Usage: ./test.sh [port] [host]
|
||||
# Default: port=8080, host=localhost
|
||||
|
||||
PORT=${1:-8080}
|
||||
HOST=${2:-localhost}
|
||||
BASE_URL="http://$HOST:$PORT"
|
||||
|
||||
echo "🧪 Testing coolLabs CDN"
|
||||
echo "📍 Target: $BASE_URL"
|
||||
echo "────────────────────────────────────────"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
test_result() {
|
||||
local test_name="$1"
|
||||
local result="$2"
|
||||
local expected="$3"
|
||||
local actual="$4"
|
||||
|
||||
if [ "$result" = "PASS" ]; then
|
||||
echo -e "${GREEN}✅ $test_name${NC}"
|
||||
((PASSED++))
|
||||
else
|
||||
echo -e "${RED}❌ $test_name${NC}"
|
||||
echo -e " Expected: $expected"
|
||||
echo -e " Actual: $actual"
|
||||
((FAILED++))
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 1: Health endpoint
|
||||
echo -e "\n🏥 Testing Health Endpoint"
|
||||
response=$(curl -s -w "HTTPSTATUS:%{http_code}" "$BASE_URL/health")
|
||||
http_code=$(echo "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
body=$(echo "$response" | sed -e 's/HTTPSTATUS:.*//g')
|
||||
|
||||
if [ "$http_code" = "200" ] && [ "$body" = "healthy" ]; then
|
||||
test_result "Health endpoint returns 200 and 'healthy'" "PASS"
|
||||
else
|
||||
test_result "Health endpoint returns 200 and 'healthy'" "FAIL" "200, healthy" "$http_code, $body"
|
||||
fi
|
||||
|
||||
# Test 2: Health endpoint headers
|
||||
echo -e "\n📋 Testing Health Endpoint Headers"
|
||||
response=$(curl -s -I "$BASE_URL/health")
|
||||
content_type=$(echo "$response" | grep -i "content-type" | tr -d '\r')
|
||||
cors_origin=$(echo "$response" | grep -i "access-control-allow-origin" | tr -d '\r')
|
||||
|
||||
if echo "$content_type" | grep -q "text/plain"; then
|
||||
test_result "Health endpoint has correct Content-Type" "PASS"
|
||||
else
|
||||
test_result "Health endpoint has correct Content-Type" "FAIL" "text/plain" "$content_type"
|
||||
fi
|
||||
|
||||
if [ "$cors_origin" = "Access-Control-Allow-Origin: *" ]; then
|
||||
test_result "Health endpoint has CORS headers" "PASS"
|
||||
else
|
||||
test_result "Health endpoint has CORS headers" "FAIL" "Access-Control-Allow-Origin: *" "$cors_origin"
|
||||
fi
|
||||
|
||||
# Test 3: JSON endpoint
|
||||
echo -e "\n📄 Testing JSON Endpoint"
|
||||
response=$(curl -s -w "HTTPSTATUS:%{http_code}" "$BASE_URL/releases.json")
|
||||
http_code=$(echo "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
body=$(echo "$response" | sed -e 's/HTTPSTATUS:.*//g' | tr -d '\r\n')
|
||||
|
||||
if [ "$http_code" = "200" ] && echo "$body" | grep -q '"url"'; then
|
||||
test_result "JSON endpoint returns 200 and valid JSON" "PASS"
|
||||
else
|
||||
test_result "JSON endpoint returns 200 and valid JSON" "FAIL" "200, valid JSON" "$http_code, $body"
|
||||
fi
|
||||
|
||||
# Test 4: JSON endpoint headers
|
||||
echo -e "\n🏷️ Testing JSON Endpoint Headers"
|
||||
response=$(curl -s -I "$BASE_URL/releases.json")
|
||||
content_type=$(echo "$response" | grep -i "content-type" | tr -d '\r')
|
||||
etag=$(echo "$response" | grep -i "etag" | tr -d '\r')
|
||||
cache_control=$(echo "$response" | grep -i "cache-control" | tr -d '\r')
|
||||
cors_origin=$(echo "$response" | grep -i "access-control-allow-origin" | tr -d '\r')
|
||||
|
||||
if echo "$content_type" | grep -q "application/json"; then
|
||||
test_result "JSON endpoint has correct Content-Type" "PASS"
|
||||
else
|
||||
test_result "JSON endpoint has correct Content-Type" "FAIL" "application/json" "$content_type"
|
||||
fi
|
||||
|
||||
if echo "$etag" | grep -i -q "etag:"; then
|
||||
test_result "JSON endpoint has ETag header" "PASS"
|
||||
else
|
||||
test_result "JSON endpoint has ETag header" "FAIL" "ETag present" "$etag"
|
||||
fi
|
||||
|
||||
if [ "$cache_control" = "Cache-Control: public, must-revalidate, max-age=600" ]; then
|
||||
test_result "JSON endpoint has correct Cache-Control" "PASS"
|
||||
else
|
||||
test_result "JSON endpoint has correct Cache-Control" "FAIL" "public, must-revalidate, max-age=600" "$cache_control"
|
||||
fi
|
||||
|
||||
if [ "$cors_origin" = "Access-Control-Allow-Origin: *" ]; then
|
||||
test_result "JSON endpoint has CORS headers" "PASS"
|
||||
else
|
||||
test_result "JSON endpoint has CORS headers" "FAIL" "Access-Control-Allow-Origin: *" "$cors_origin"
|
||||
fi
|
||||
|
||||
# Test 5: ETag caching
|
||||
echo -e "\n🏷️ Testing ETag Caching"
|
||||
# First get the ETag
|
||||
response=$(curl -s -I "$BASE_URL/releases.json")
|
||||
etag=$(echo "$response" | grep -i "etag" | tr -d '\r' | sed 's/ETag: //I')
|
||||
|
||||
# Then test with If-None-Match
|
||||
response=$(curl -s -w "HTTPSTATUS:%{http_code}" -H "If-None-Match: $etag" "$BASE_URL/releases.json")
|
||||
http_code=$(echo "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$http_code" = "304" ]; then
|
||||
test_result "ETag caching returns 304 Not Modified" "PASS"
|
||||
else
|
||||
test_result "ETag caching returns 304 Not Modified" "FAIL" "304" "$http_code"
|
||||
fi
|
||||
|
||||
# Test 6: Root redirect
|
||||
echo -e "\n🏠 Testing Root Redirect"
|
||||
response=$(curl -s -w "HTTPSTATUS:%{http_code}" -I "$BASE_URL/")
|
||||
http_code=$(echo "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
location=$(echo "$response" | grep -i "location" | tr -d '\r')
|
||||
|
||||
if [ "$http_code" = "302" ] && echo "$location" | grep -q "https://"; then
|
||||
test_result "Root path redirects to configured domain" "PASS"
|
||||
else
|
||||
test_result "Root path redirects to configured domain" "FAIL" "302, https://<domain>" "$http_code, $location"
|
||||
fi
|
||||
|
||||
# Test 7: 404 redirect
|
||||
echo -e "\n❓ Testing 404 Redirect"
|
||||
response=$(curl -s -w "HTTPSTATUS:%{http_code}" -I "$BASE_URL/nonexistent.json")
|
||||
http_code=$(echo "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
location=$(echo "$response" | grep -i "location" | tr -d '\r')
|
||||
|
||||
if [ "$http_code" = "302" ] && echo "$location" | grep -q "https://" && echo "$location" | grep -q "nonexistent.json"; then
|
||||
test_result "404 redirects to configured domain with path" "PASS"
|
||||
else
|
||||
test_result "404 redirects to configured domain with path" "FAIL" "302, https://<domain>/nonexistent.json" "$http_code, $location"
|
||||
fi
|
||||
|
||||
# Test 8: OPTIONS request (CORS preflight)
|
||||
echo -e "\n🌐 Testing CORS Preflight"
|
||||
response=$(curl -s -w "HTTPSTATUS:%{http_code}" -X OPTIONS "$BASE_URL/releases.json")
|
||||
http_code=$(echo "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$http_code" = "204" ]; then
|
||||
test_result "OPTIONS request returns 204 No Content" "PASS"
|
||||
else
|
||||
test_result "OPTIONS request returns 204 No Content" "FAIL" "204" "$http_code"
|
||||
fi
|
||||
|
||||
# Test 9: HEAD request
|
||||
echo -e "\n📋 Testing HEAD Request"
|
||||
response=$(curl -s -w "HTTPSTATUS:%{http_code}" -I "$BASE_URL/releases.json")
|
||||
http_code=$(echo "$response" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
|
||||
|
||||
if [ "$http_code" = "200" ]; then
|
||||
test_result "HEAD request returns 200 OK" "PASS"
|
||||
else
|
||||
test_result "HEAD request returns 200 OK" "FAIL" "200" "$http_code"
|
||||
fi
|
||||
|
||||
# Test 10: PNG image endpoint
|
||||
echo -e "\n🖼️ Testing PNG Image Endpoint"
|
||||
http_code=$(curl -s -w "%{http_code}" -o /dev/null "$BASE_URL/cl-logo.png")
|
||||
content_length=$(curl -s -I "$BASE_URL/cl-logo.png" | grep -i "content-length" | tr -d '\r' | sed 's/.*: //')
|
||||
|
||||
if [ "$http_code" = "200" ] && [ -n "$content_length" ] && [ "$content_length" -gt 0 ]; then
|
||||
test_result "PNG image endpoint returns 200 and content" "PASS"
|
||||
else
|
||||
test_result "PNG image endpoint returns 200 and content" "FAIL" "200, content" "$http_code, length=$content_length"
|
||||
fi
|
||||
|
||||
# Test 11: PNG image headers
|
||||
echo -e "\n🏷️ Testing PNG Image Headers"
|
||||
response=$(curl -s -I "$BASE_URL/cl-logo.png")
|
||||
content_type=$(echo "$response" | grep -i "content-type" | tr -d '\r')
|
||||
etag=$(echo "$response" | grep -i "etag" | tr -d '\r')
|
||||
cache_control=$(echo "$response" | grep -i "cache-control" | tr -d '\r')
|
||||
cors_origin=$(echo "$response" | grep -i "access-control-allow-origin" | tr -d '\r')
|
||||
|
||||
if echo "$content_type" | grep -q "image/png"; then
|
||||
test_result "PNG image has correct Content-Type" "PASS"
|
||||
else
|
||||
test_result "PNG image has correct Content-Type" "FAIL" "image/png" "$content_type"
|
||||
fi
|
||||
|
||||
if echo "$etag" | grep -i -q "etag:"; then
|
||||
test_result "PNG image has ETag header" "PASS"
|
||||
else
|
||||
test_result "PNG image has ETag header" "FAIL" "ETag present" "$etag"
|
||||
fi
|
||||
|
||||
if echo "$cache_control" | grep -q "public"; then
|
||||
test_result "PNG image has Cache-Control header" "PASS"
|
||||
else
|
||||
test_result "PNG image has Cache-Control header" "FAIL" "Cache-Control present" "$cache_control"
|
||||
fi
|
||||
|
||||
if [ "$cors_origin" = "Access-Control-Allow-Origin: *" ]; then
|
||||
test_result "PNG image has CORS headers" "PASS"
|
||||
else
|
||||
test_result "PNG image has CORS headers" "FAIL" "Access-Control-Allow-Origin: *" "$cors_origin"
|
||||
fi
|
||||
|
||||
# Test 12: WEBP image endpoint
|
||||
echo -e "\n🖼️ Testing WEBP Image Endpoint"
|
||||
http_code=$(curl -s -w "%{http_code}" -o /dev/null "$BASE_URL/discord-support-search1.webp")
|
||||
content_length=$(curl -s -I "$BASE_URL/discord-support-search1.webp" | grep -i "content-length" | tr -d '\r' | sed 's/.*: //')
|
||||
|
||||
if [ "$http_code" = "200" ] && [ -n "$content_length" ] && [ "$content_length" -gt 0 ]; then
|
||||
test_result "WEBP image endpoint returns 200 and content" "PASS"
|
||||
else
|
||||
test_result "WEBP image endpoint returns 200 and content" "FAIL" "200, content" "$http_code, length=$content_length"
|
||||
fi
|
||||
|
||||
# Test 13: WEBP image headers
|
||||
echo -e "\n🏷️ Testing WEBP Image Headers"
|
||||
response=$(curl -s -I "$BASE_URL/discord-support-search1.webp")
|
||||
content_type=$(echo "$response" | grep -i "content-type" | tr -d '\r')
|
||||
|
||||
if echo "$content_type" | grep -q "image/webp"; then
|
||||
test_result "WEBP image has correct Content-Type" "PASS"
|
||||
else
|
||||
test_result "WEBP image has correct Content-Type" "FAIL" "image/webp" "$content_type"
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo -e "\n────────────────────────────────────────"
|
||||
echo -e "📊 Test Summary:"
|
||||
echo -e " ${GREEN}Passed: $PASSED${NC}"
|
||||
echo -e " ${RED}Failed: $FAILED${NC}"
|
||||
echo -e " Total: $((PASSED + FAILED))"
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "\n${GREEN}🎉 All tests passed! The CDN is working correctly.${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}⚠️ Some tests failed. Please check the implementation.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user