package httpapi import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "sort" "strings" "time" "ipecho/internal/clientip" ) type Server struct { resolver clientip.Resolver logger *slog.Logger } type ClientInfo struct { IP string `json:"ip"` RemoteAddr string `json:"remote_addr"` Method string `json:"method"` Path string `json:"path"` UserAgent string `json:"user_agent"` Headers map[string][]string `json:"headers"` } func New(resolver clientip.Resolver) http.Handler { return NewWithLogger(resolver, discardLogger()) } func NewWithLogger(resolver clientip.Resolver, logger *slog.Logger) http.Handler { if logger == nil { logger = discardLogger() } server := &Server{ resolver: resolver, logger: logger, } mux := http.NewServeMux() mux.HandleFunc("GET /{$}", server.handleRoot) mux.HandleFunc("GET /ip", server.handleIP) mux.HandleFunc("GET /json", server.handleJSON) mux.HandleFunc("GET /headers", server.handleHeaders) mux.HandleFunc("GET /user-agent", server.handleUserAgent) mux.HandleFunc("GET /healthz", server.handleHealth) mux.HandleFunc("GET /favicon.ico", server.handleFavicon) return accessLog(secureHeaders(mux), logger, resolver) } func (s *Server) handleRoot(w http.ResponseWriter, req *http.Request) { if wantsHTML(req) { s.handleHome(w, req) return } s.handleIP(w, req) } func (s *Server) handleIP(w http.ResponseWriter, req *http.Request) { writePlain(w, http.StatusOK, s.resolver.FromRequest(req)) } func (s *Server) handleJSON(w http.ResponseWriter, req *http.Request) { info := s.clientInfo(req) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) encoder := json.NewEncoder(w) encoder.SetIndent("", " ") _ = encoder.Encode(info) } func (s *Server) handleHeaders(w http.ResponseWriter, req *http.Request) { writePlain(w, http.StatusOK, formatHeaders(req)) } func (s *Server) handleUserAgent(w http.ResponseWriter, req *http.Request) { writePlain(w, http.StatusOK, req.UserAgent()) } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { writePlain(w, http.StatusOK, "ok") } func (s *Server) clientInfo(req *http.Request) ClientInfo { return ClientInfo{ IP: s.resolver.FromRequest(req), RemoteAddr: req.RemoteAddr, Method: req.Method, Path: req.URL.Path, UserAgent: req.UserAgent(), Headers: cloneHeaders(req), } } func secureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'") next.ServeHTTP(w, req) }) } func accessLog(next http.Handler, logger *slog.Logger, resolver clientip.Resolver) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { startedAt := time.Now() recorder := &responseRecorder{ResponseWriter: w} next.ServeHTTP(recorder, req) status := recorder.status if status == 0 { status = http.StatusOK } logger.Info("http_request", "method", req.Method, "path", req.URL.Path, "status", status, "bytes", recorder.bytes, "duration", time.Since(startedAt).String(), "client_ip", resolver.FromRequest(req), "remote_addr", req.RemoteAddr, "user_agent", req.UserAgent(), ) }) } type responseRecorder struct { http.ResponseWriter status int bytes int } func (r *responseRecorder) WriteHeader(status int) { if r.status != 0 { return } r.status = status r.ResponseWriter.WriteHeader(status) } func (r *responseRecorder) Write(body []byte) (int, error) { if r.status == 0 { r.WriteHeader(http.StatusOK) } written, err := r.ResponseWriter.Write(body) r.bytes += written return written, err } func (r *responseRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } func wantsHTML(req *http.Request) bool { for _, value := range req.Header.Values("Accept") { for _, part := range strings.Split(value, ",") { mediaType := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) if mediaType == "text/html" || mediaType == "application/xhtml+xml" { return true } } } return false } func writePlain(w http.ResponseWriter, status int, body string) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(status) _, _ = fmt.Fprintln(w, body) } func cloneHeaders(req *http.Request) map[string][]string { headers := make(map[string][]string, len(req.Header)+1) headers["Host"] = []string{req.Host} for name, values := range req.Header { headers[name] = append([]string(nil), values...) } return headers } func formatHeaders(req *http.Request) string { headers := cloneHeaders(req) names := make([]string, 0, len(headers)) for name := range headers { names = append(names, name) } sort.Strings(names) var builder strings.Builder for _, name := range names { for _, value := range headers[name] { builder.WriteString(name) builder.WriteString(": ") builder.WriteString(value) builder.WriteByte('\n') } } return strings.TrimRight(builder.String(), "\n") } func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }