restructure project, add claudemd
This commit is contained in:
57
go/httputil/cors.go
Normal file
57
go/httputil/cors.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"kjol/appenv"
|
||||
)
|
||||
|
||||
// CorsConfig configures CorsMiddleware. AllowedDomains is consulted only outside
|
||||
// development (in development every origin is allowed). BundleVersion, when set,
|
||||
// is called to stamp the X-Bundle-Version header on /api/ responses; leave it nil
|
||||
// to skip that header.
|
||||
type CorsConfig struct {
|
||||
AllowedDomains []string
|
||||
BundleVersion func() string
|
||||
}
|
||||
|
||||
// CorsMiddleware returns middleware that applies CORS headers to all requests.
|
||||
// It is a constructor (not the middleware itself) so the app can inject its
|
||||
// allowed domains and bundle-version source without this package importing app
|
||||
// config or handlers.
|
||||
func CorsMiddleware(cfg CorsConfig) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if appenv.Environment != appenv.EnvTypeDevelopment {
|
||||
if len(cfg.AllowedDomains) > 0 && slices.Contains(cfg.AllowedDomains, origin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
}
|
||||
} else {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-Bundle-Version")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
if cfg.BundleVersion != nil {
|
||||
if version := cfg.BundleVersion(); version != "" && strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("X-Bundle-Version", version)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle preflight OPTIONS request
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
45
go/httputil/cors_test.go
Normal file
45
go/httputil/cors_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package httputil_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"kjol/httputil"
|
||||
)
|
||||
|
||||
func TestCorsMiddleware_SetsBundleVersionOnAPI(t *testing.T) {
|
||||
const testVersion = "test-bundle-version"
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := httputil.CorsMiddleware(httputil.CorsConfig{
|
||||
BundleVersion: func() string { return testVersion },
|
||||
})(mux)
|
||||
|
||||
t.Run("api route", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if got := rec.Header().Get("X-Bundle-Version"); got != testVersion {
|
||||
t.Fatalf("expected X-Bundle-Version %q, got %q", testVersion, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-api route", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if got := rec.Header().Get("X-Bundle-Version"); got != "" {
|
||||
t.Fatalf("expected no bundle header on non-API route, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
5
go/httputil/doc.go
Normal file
5
go/httputil/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package httputil holds small, dependency-free HTTP helpers shared across apps:
|
||||
// CORS middleware, JSON response helpers, and user-agent parsing. Anything that
|
||||
// needs the application's auth/session/permission model lives app-side (in
|
||||
// internal/httpauth), not here, so this package never imports app code.
|
||||
package httputil
|
||||
20
go/httputil/respond.go
Normal file
20
go/httputil/respond.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func RespondJSON(w http.ResponseWriter, statusCode int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func RespondError(w http.ResponseWriter, statusCode int, message string) {
|
||||
RespondJSON(w, statusCode, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func RespondSuccess(w http.ResponseWriter) {
|
||||
RespondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
138
go/httputil/useragent.go
Normal file
138
go/httputil/useragent.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func ParseUserAgent(ua string) string {
|
||||
if ua == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
browser := parseBrowser(ua)
|
||||
os := parseOS(ua)
|
||||
|
||||
if browser == "" && os == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
if browser == "" {
|
||||
return os
|
||||
}
|
||||
if os == "" {
|
||||
return browser
|
||||
}
|
||||
|
||||
return browser + " on " + os
|
||||
}
|
||||
|
||||
func parseBrowser(ua string) string {
|
||||
if match := regexp.MustCompile(`Edg(?:e|A|iOS)?/(\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Edge " + match[1]
|
||||
}
|
||||
|
||||
if match := regexp.MustCompile(`(?:OPR|Opera)[/ ](\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Opera " + match[1]
|
||||
}
|
||||
|
||||
if match := regexp.MustCompile(`SamsungBrowser/(\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Samsung Browser " + match[1]
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "Chrome") && !strings.Contains(ua, "Chromium") {
|
||||
if match := regexp.MustCompile(`Chrome/(\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Chrome " + match[1]
|
||||
}
|
||||
}
|
||||
|
||||
if match := regexp.MustCompile(`Chromium/(\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Chromium " + match[1]
|
||||
}
|
||||
|
||||
if match := regexp.MustCompile(`Firefox/(\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Firefox " + match[1]
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "Safari") && !strings.Contains(ua, "Chrome") {
|
||||
if match := regexp.MustCompile(`Version/(\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Safari " + match[1]
|
||||
}
|
||||
return "Safari"
|
||||
}
|
||||
|
||||
if match := regexp.MustCompile(`MSIE (\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Internet Explorer " + match[1]
|
||||
}
|
||||
if strings.Contains(ua, "Trident/") {
|
||||
if match := regexp.MustCompile(`rv:(\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Internet Explorer " + match[1]
|
||||
}
|
||||
return "Internet Explorer"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseOS(ua string) string {
|
||||
if strings.Contains(ua, "iPhone") {
|
||||
if match := regexp.MustCompile(`iPhone OS (\d+)[_\d]*`).FindStringSubmatch(ua); match != nil {
|
||||
return "iOS " + match[1]
|
||||
}
|
||||
return "iOS"
|
||||
}
|
||||
if strings.Contains(ua, "iPad") {
|
||||
if match := regexp.MustCompile(`CPU OS (\d+)[_\d]*`).FindStringSubmatch(ua); match != nil {
|
||||
return "iPadOS " + match[1]
|
||||
}
|
||||
return "iPadOS"
|
||||
}
|
||||
|
||||
if match := regexp.MustCompile(`Android (\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "Android " + match[1]
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "Windows") {
|
||||
if strings.Contains(ua, "Windows NT 10.0") {
|
||||
return "Windows 10/11"
|
||||
}
|
||||
if strings.Contains(ua, "Windows NT 6.3") {
|
||||
return "Windows 8.1"
|
||||
}
|
||||
if strings.Contains(ua, "Windows NT 6.2") {
|
||||
return "Windows 8"
|
||||
}
|
||||
if strings.Contains(ua, "Windows NT 6.1") {
|
||||
return "Windows 7"
|
||||
}
|
||||
if strings.Contains(ua, "Windows NT 6.0") {
|
||||
return "Windows Vista"
|
||||
}
|
||||
if strings.Contains(ua, "Windows NT 5.1") {
|
||||
return "Windows XP"
|
||||
}
|
||||
return "Windows"
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh") {
|
||||
if match := regexp.MustCompile(`Mac OS X (\d+)[_.](\d+)`).FindStringSubmatch(ua); match != nil {
|
||||
return "macOS " + match[1] + "." + match[2]
|
||||
}
|
||||
return "macOS"
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "Ubuntu") {
|
||||
return "Ubuntu"
|
||||
}
|
||||
if strings.Contains(ua, "Fedora") {
|
||||
return "Fedora"
|
||||
}
|
||||
if strings.Contains(ua, "Linux") {
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "CrOS") {
|
||||
return "Chrome OS"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user