first commit
This commit is contained in:
225
internal/httpapi/server.go
Normal file
225
internal/httpapi/server.go
Normal file
@@ -0,0 +1,225 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user