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:
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user