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:
118
backend/libs/config/config.go
Normal file
118
backend/libs/config/config.go
Normal 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))
|
||||
}
|
||||
34
backend/libs/config/config_test.go
Normal file
34
backend/libs/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
34
backend/libs/handlers/app.go
Normal file
34
backend/libs/handlers/app.go
Normal 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())
|
||||
}
|
||||
20
backend/libs/handlers/health.go
Normal file
20
backend/libs/handlers/health.go
Normal 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),
|
||||
})
|
||||
}
|
||||
21
backend/libs/handlers/pages.go
Normal file
21
backend/libs/handlers/pages.go
Normal 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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
31
backend/libs/handlers/static.go
Normal file
31
backend/libs/handlers/static.go
Normal 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")
|
||||
}
|
||||
}
|
||||
26
backend/libs/handlers/static_test.go
Normal file
26
backend/libs/handlers/static_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
backend/libs/handlers/upload.go
Normal file
19
backend/libs/handlers/upload.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
18
backend/libs/helpers/human.go
Normal file
18
backend/libs/helpers/human.go
Normal 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])
|
||||
}
|
||||
19
backend/libs/helpers/human_test.go
Normal file
19
backend/libs/helpers/human_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
backend/libs/helpers/json.go
Normal file
20
backend/libs/helpers/json.go
Normal 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})
|
||||
}
|
||||
42
backend/libs/httpserver/server.go
Normal file
42
backend/libs/httpserver/server.go
Normal 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
|
||||
}
|
||||
12
backend/libs/middleware/chain.go
Normal file
12
backend/libs/middleware/chain.go
Normal 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
|
||||
}
|
||||
69
backend/libs/middleware/gzip.go
Normal file
69
backend/libs/middleware/gzip.go
Normal 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
|
||||
}
|
||||
}
|
||||
63
backend/libs/middleware/gzip_test.go
Normal file
63
backend/libs/middleware/gzip_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
54
backend/libs/middleware/logger.go
Normal file
54
backend/libs/middleware/logger.go
Normal 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(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
26
backend/libs/middleware/recoverer.go
Normal file
26
backend/libs/middleware/recoverer.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
38
backend/libs/middleware/request_id.go
Normal file
38
backend/libs/middleware/request_id.go
Normal 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[:])
|
||||
}
|
||||
16
backend/libs/middleware/security.go
Normal file
16
backend/libs/middleware/security.go
Normal 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)
|
||||
})
|
||||
}
|
||||
30
backend/libs/services/upload.go
Normal file
30
backend/libs/services/upload.go
Normal 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
|
||||
}
|
||||
79
backend/libs/web/renderer.go
Normal file
79
backend/libs/web/renderer.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user