feat: initialize warpbox.dev project structure and backend

Initialize the repository with the core Go backend architecture and a frontend mockup for warpbox.dev, a self-hosted file-sharing application.

- Set up Go backend modules for configuration, HTTP server, middleware, handlers, and templates.
- Add local development scripts, environment templates, and basic project configuration.
- Include a React-based frontend mockup under the docs directory.
This commit is contained in:
2026-05-25 15:36:49 +03:00
parent 84e5aee87c
commit 9b8ef16474
129 changed files with 19863 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
AppName string
Environment string
Addr string
BaseURL string
StaticDir string
TemplateDir string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
MaxUploadSize int64
}
func Load() (Config, error) {
cfg := Config{
AppName: envString("WARPBOX_APP_NAME", "warpbox.dev"),
Environment: envString("WARPBOX_ENV", "development"),
Addr: envString("WARPBOX_ADDR", ":8080"),
BaseURL: strings.TrimRight(envString("WARPBOX_BASE_URL", "http://localhost:8080"), "/"),
StaticDir: envString("WARPBOX_STATIC_DIR", defaultPath("static")),
TemplateDir: envString("WARPBOX_TEMPLATE_DIR", defaultPath("templates")),
ReadTimeout: envDuration("WARPBOX_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDuration("WARPBOX_WRITE_TIMEOUT", 60*time.Second),
IdleTimeout: envDuration("WARPBOX_IDLE_TIMEOUT", 120*time.Second),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default.
}
if cfg.BaseURL == "" {
return Config{}, fmt.Errorf("WARPBOX_BASE_URL cannot be empty")
}
if cfg.MaxUploadSize <= 0 {
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive")
}
return cfg, nil
}
func defaultPath(name string) string {
candidates := []string{
filepath.Join("backend", name),
name,
}
for _, candidate := range candidates {
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
}
return filepath.Join("backend", name)
}
func envString(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}
func envMegabytes(key string, fallback float64) int64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return megabytesToBytes(fallback)
}
parsed, err := parseMegabytes(value)
if err != nil {
return megabytesToBytes(fallback)
}
return parsed
}
func parseMegabytes(value string) (int64, error) {
normalized := strings.TrimSpace(value)
normalized = strings.TrimSuffix(normalized, "MB")
normalized = strings.TrimSuffix(normalized, "Mb")
normalized = strings.TrimSuffix(normalized, "mb")
normalized = strings.TrimSpace(normalized)
sizeMB, err := strconv.ParseFloat(normalized, 64)
if err != nil {
return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err)
}
if sizeMB <= 0 {
return 0, fmt.Errorf("megabyte value must be positive")
}
return megabytesToBytes(sizeMB), nil
}
func megabytesToBytes(sizeMB float64) int64 {
return int64(math.Round(sizeMB * 1024 * 1024))
}

View File

@@ -0,0 +1,34 @@
package config
import "testing"
func TestParseMegabytes(t *testing.T) {
tests := map[string]int64{
"0.5": 512 * 1024,
"0.5Mb": 512 * 1024,
"1mb": 1024 * 1024,
"1MB": 1024 * 1024,
"1.5Mb": 1536 * 1024,
" 2 ": 2 * 1024 * 1024,
}
for input, want := range tests {
got, err := parseMegabytes(input)
if err != nil {
t.Fatalf("parseMegabytes(%q) returned error: %v", input, err)
}
if got != want {
t.Fatalf("parseMegabytes(%q) = %d, want %d", input, got, want)
}
}
}
func TestParseMegabytesRejectsInvalidValues(t *testing.T) {
tests := []string{"", "0", "-1", "abc"}
for _, input := range tests {
if _, err := parseMegabytes(input); err == nil {
t.Fatalf("parseMegabytes(%q) returned nil error", input)
}
}
}

View File

@@ -0,0 +1,34 @@
package handlers
import (
"log/slog"
"net/http"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
type App struct {
cfg config.Config
logger *slog.Logger
renderer *web.Renderer
uploadService *services.UploadService
}
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService) *App {
return &App{
cfg: cfg,
logger: logger,
renderer: renderer,
uploadService: uploadService,
}
}
func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /healthz", a.Health)
mux.HandleFunc("GET /api/v1/health", a.Health)
mux.HandleFunc("POST /api/v1/upload", a.UploadPlaceholder)
mux.Handle("GET /static/", a.Static())
}

View File

@@ -0,0 +1,20 @@
package handlers
import (
"net/http"
"time"
"warpbox.dev/backend/libs/helpers"
)
type healthResponse struct {
Status string `json:"status"`
Time string `json:"time"`
}
func (a *App) Health(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusOK, healthResponse{
Status: "ok",
Time: time.Now().UTC().Format(time.RFC3339),
})
}

View File

@@ -0,0 +1,21 @@
package handlers
import (
"net/http"
"warpbox.dev/backend/libs/web"
)
type homeData struct {
MaxUploadSize string
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "home.gohtml", web.PageData{
Title: "Upload your files",
Description: "Upload and share files through a self-hosted Warpbox instance.",
Data: homeData{
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
},
})
}

View File

@@ -0,0 +1,31 @@
package handlers
import (
"net/http"
"path/filepath"
"strings"
)
func (a *App) Static() http.Handler {
fileServer := http.StripPrefix("/static/", http.FileServer(http.Dir(a.cfg.StaticDir)))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setStaticCacheHeaders(w, r.URL.Path)
fileServer.ServeHTTP(w, r)
})
}
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".avif", ".gif", ".ico", ".jpg", ".jpeg", ".png", ".svg", ".webp",
".mp4", ".m4v", ".mov", ".webm", ".mp3", ".ogg",
".eot", ".otf", ".ttf", ".woff", ".woff2":
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
case ".css", ".js":
w.Header().Set("Cache-Control", "public, max-age=86400")
default:
w.Header().Set("Cache-Control", "public, max-age=3600")
}
}

View File

@@ -0,0 +1,26 @@
package handlers
import (
"net/http/httptest"
"testing"
)
func TestSetStaticCacheHeaders(t *testing.T) {
tests := map[string]string{
"/static/css/app.css": "public, max-age=86400",
"/static/js/app.js": "public, max-age=86400",
"/static/img/preview.webp": "public, max-age=31536000, immutable",
"/static/fonts/ui.woff2": "public, max-age=31536000, immutable",
"/static/videos/intro.mp4": "public, max-age=31536000, immutable",
"/static/uploads/file.data": "public, max-age=3600",
}
for path, want := range tests {
response := httptest.NewRecorder()
setStaticCacheHeaders(response, path)
if got := response.Header().Get("Cache-Control"); got != want {
t.Fatalf("Cache-Control for %s = %q, want %q", path, got, want)
}
}
}

View File

@@ -0,0 +1,19 @@
package handlers
import (
"net/http"
"warpbox.dev/backend/libs/helpers"
)
type uploadPlaceholderResponse struct {
Message string `json:"message"`
MaxUploadSize string `json:"maxUploadSize"`
}
func (a *App) UploadPlaceholder(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSON(w, http.StatusNotImplemented, uploadPlaceholderResponse{
Message: "upload storage is not implemented yet; backend base is ready for the upload pipeline",
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
})
}

View File

@@ -0,0 +1,18 @@
package helpers
import "fmt"
func FormatBytes(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
}

View File

@@ -0,0 +1,19 @@
package helpers
import "testing"
func TestFormatBytes(t *testing.T) {
tests := map[int64]string{
0: "0 B",
512: "512 B",
1024: "1.0 KiB",
1536: "1.5 KiB",
1073741824: "1.0 GiB",
}
for input, want := range tests {
if got := FormatBytes(input); got != want {
t.Fatalf("FormatBytes(%d) = %q, want %q", input, got, want)
}
}
}

View File

@@ -0,0 +1,20 @@
package helpers
import (
"encoding/json"
"net/http"
)
type ErrorResponse struct {
Error string `json:"error"`
}
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func WriteJSONError(w http.ResponseWriter, status int, message string) {
WriteJSON(w, status, ErrorResponse{Error: message})
}

View File

@@ -0,0 +1,42 @@
package httpserver
import (
"log/slog"
"net/http"
"warpbox.dev/backend/libs/config"
"warpbox.dev/backend/libs/handlers"
"warpbox.dev/backend/libs/middleware"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.BaseURL)
if err != nil {
return nil, err
}
uploadService := services.NewUploadService(cfg.MaxUploadSize)
app := handlers.NewApp(cfg, logger, renderer, uploadService)
router := http.NewServeMux()
app.RegisterRoutes(router)
handler := middleware.Chain(
router,
middleware.Recoverer(logger),
middleware.RequestID,
middleware.SecurityHeaders,
middleware.Gzip,
middleware.Logger(logger),
)
return &http.Server{
Addr: cfg.Addr,
Handler: handler,
ReadTimeout: cfg.ReadTimeout,
WriteTimeout: cfg.WriteTimeout,
IdleTimeout: cfg.IdleTimeout,
}, nil
}

View File

@@ -0,0 +1,12 @@
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
func Chain(handler http.Handler, middleware ...Middleware) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
handler = middleware[i](handler)
}
return handler
}

View File

@@ -0,0 +1,69 @@
package middleware
import (
"compress/gzip"
"io"
"net/http"
"strings"
)
type gzipResponseWriter struct {
http.ResponseWriter
writer io.Writer
wrote bool
}
func (w *gzipResponseWriter) WriteHeader(status int) {
if w.wrote {
return
}
w.wrote = true
header := w.Header()
header.Del("Content-Length")
header.Del("Accept-Ranges")
header.Set("Content-Encoding", "gzip")
header.Add("Vary", "Accept-Encoding")
w.ResponseWriter.WriteHeader(status)
}
func (w *gzipResponseWriter) Write(data []byte) (int, error) {
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
return w.writer.Write(data)
}
func Gzip(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !acceptsGzip(r) || shouldSkipGzip(r) {
next.ServeHTTP(w, r)
return
}
gz := gzip.NewWriter(w)
defer gz.Close()
next.ServeHTTP(&gzipResponseWriter{
ResponseWriter: w,
writer: gz,
}, r)
})
}
func acceptsGzip(r *http.Request) bool {
return strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
}
func shouldSkipGzip(r *http.Request) bool {
if r.Method == http.MethodHead || r.Header.Get("Range") != "" {
return true
}
path := r.URL.Path
switch ext := strings.ToLower(path[strings.LastIndex(path, ".")+1:]); ext {
case "br", "gz", "zip", "7z", "rar", "jpg", "jpeg", "png", "gif", "webp", "avif", "mp4", "webm", "mov", "m4v", "mp3", "ogg", "woff", "woff2", "ttf", "otf":
return true
default:
return false
}
}

View File

@@ -0,0 +1,63 @@
package middleware
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestGzipCompressesEligibleResponses(t *testing.T) {
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "11")
_, _ = io.WriteString(w, "hello world")
}))
request := httptest.NewRequest(http.MethodGet, "/page", nil)
request.Header.Set("Accept-Encoding", "gzip")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if got := response.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := response.Header().Get("Content-Length"); got != "" {
t.Fatalf("Content-Length = %q, want empty for gzipped response", got)
}
if got := response.Header().Get("Vary"); got != "Accept-Encoding" {
t.Fatalf("Vary = %q, want Accept-Encoding", got)
}
}
func TestGzipSkipsRangeAndHeadRequests(t *testing.T) {
handler := Gzip(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "hello world")
}))
tests := []struct {
name string
method string
rangeHeader string
}{
{name: "range", method: http.MethodGet, rangeHeader: "bytes=0-4"},
{name: "head", method: http.MethodHead},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := httptest.NewRequest(tt.method, "/asset.css", nil)
request.Header.Set("Accept-Encoding", "gzip")
if tt.rangeHeader != "" {
request.Header.Set("Range", tt.rangeHeader)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if got := response.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
})
}
}

View File

@@ -0,0 +1,54 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(data []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(data)
r.bytes += n
return n, err
}
func Logger(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(recorder, r)
status := recorder.status
if status == 0 {
status = http.StatusOK
}
logger.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"bytes", recorder.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFromContext(r.Context()),
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
})
}
}

View File

@@ -0,0 +1,26 @@
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
)
func Recoverer(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
logger.Error("panic recovered",
"error", recovered,
"stack", string(debug.Stack()),
"request_id", RequestIDFromContext(r.Context()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,38 @@
package middleware
import (
"context"
"crypto/rand"
"encoding/hex"
"net/http"
)
type requestIDKey struct{}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = newRequestID()
}
w.Header().Set("X-Request-ID", requestID)
ctx := context.WithValue(r.Context(), requestIDKey{}, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequestIDFromContext(ctx context.Context) string {
if value, ok := ctx.Value(requestIDKey{}).(string); ok {
return value
}
return ""
}
func newRequestID() string {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return "request-id-unavailable"
}
return hex.EncodeToString(data[:])
}

View File

@@ -0,0 +1,16 @@
package middleware
import "net/http"
func SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := w.Header()
header.Set("X-Content-Type-Options", "nosniff")
header.Set("X-Frame-Options", "DENY")
header.Set("Referrer-Policy", "strict-origin-when-cross-origin")
header.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
header.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self'; style-src 'self'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,30 @@
package services
import (
"fmt"
"warpbox.dev/backend/libs/helpers"
)
type UploadService struct {
maxUploadSize int64
}
func NewUploadService(maxUploadSize int64) *UploadService {
return &UploadService{maxUploadSize: maxUploadSize}
}
func (s *UploadService) MaxUploadSize() int64 {
return s.maxUploadSize
}
func (s *UploadService) MaxUploadSizeLabel() string {
return helpers.FormatBytes(s.maxUploadSize)
}
func (s *UploadService) ValidateSize(size int64) error {
if size > s.maxUploadSize {
return fmt.Errorf("file exceeds max upload size of %s", s.MaxUploadSizeLabel())
}
return nil
}

View File

@@ -0,0 +1,79 @@
package web
import (
"html/template"
"net/http"
"path/filepath"
"time"
)
type Renderer struct {
templates map[string]*template.Template
appName string
baseURL string
}
type PageData struct {
AppName string
BaseURL string
Title string
Description string
CurrentYear int
Data any
}
func NewRenderer(templateDir, appName, baseURL string) (*Renderer, error) {
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.gohtml"))
if err != nil {
return nil, err
}
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.gohtml"))
if err != nil {
return nil, err
}
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.gohtml"))
if err != nil {
return nil, err
}
templates := make(map[string]*template.Template, len(pages))
for _, page := range pages {
files := append([]string{}, layouts...)
files = append(files, partials...)
files = append(files, page)
parsed, err := template.ParseFiles(files...)
if err != nil {
return nil, err
}
templates[filepath.Base(page)] = parsed
}
return &Renderer{
templates: templates,
appName: appName,
baseURL: baseURL,
}, nil
}
func (r *Renderer) Render(w http.ResponseWriter, status int, page string, data PageData) {
data.AppName = r.appName
data.BaseURL = r.baseURL
data.CurrentYear = time.Now().Year()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
tmpl, ok := r.templates[page]
if !ok {
http.Error(w, "template not found", http.StatusInternalServerError)
return
}
if err := tmpl.ExecuteTemplate(w, page, data); err != nil {
http.Error(w, "template render error", http.StatusInternalServerError)
}
}