// Package httpapi implements the server side of the ADR-0005 Transparency // Log HTTP contract: POST /api/v1/log/entries. package httpapi import ( "encoding/json" "errors" "fmt" "io" "net/http" "basicstack.de/proofedmesh/services/transparency-log/pkg/backend" ) // Server is the HTTP handler. Mount it on /api/v1/log/entries via Handler(). // Authentication (Pocket-ID bearer tokens per ADR-0005) is intentionally // NOT in this struct; it is wired by cmd/transparency-log/main.go through // a middleware so that the dev/CI path can run without an IdP. type Server struct { Backend backend.Backend MaxBody int64 // 0 → default 1 MiB LogError func(string, ...any) } // Handler returns an http.Handler that serves the ADR-0005 routes plus a // liveness check at GET /healthz. Everything else is 404. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit)) mux.Handle("GET /api/v1/log/entries/{hash}", http.HandlerFunc(s.handleGetEntry)) mux.Handle("GET /healthz", http.HandlerFunc(s.handleHealth)) return mux } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) } func (s *Server) handleGetEntry(w http.ResponseWriter, r *http.Request) { hash := r.PathValue("hash") if hash == "" { writeError(w, http.StatusBadRequest, "content_hash is required") return } entry, err := s.Backend.Get(r.Context(), hash) switch { case errors.Is(err, backend.ErrEntryNotFound): writeError(w, http.StatusNotFound, "entry not found") return case errors.Is(err, backend.ErrTrillianUnavailable): writeError(w, http.StatusServiceUnavailable, err.Error()) return case err != nil: s.logErr("transparency-log get: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, entry) } type submitRequest struct { Envelope backend.Envelope `json:"envelope"` ContentHash string `json:"content_hash"` } type errorBody struct { Error string `json:"error"` } func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) { if ct := r.Header.Get("Content-Type"); ct != "" && ct != "application/json" && !isJSONWithCharset(ct) { writeError(w, http.StatusBadRequest, "content-type must be application/json") return } max := s.MaxBody if max <= 0 { max = 1 << 20 } raw, err := io.ReadAll(io.LimitReader(r.Body, max+1)) if err != nil { writeError(w, http.StatusBadRequest, "read body: "+err.Error()) return } if int64(len(raw)) > max { writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("body exceeds %d bytes", max)) return } var req submitRequest dec := json.NewDecoder(bytesReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "decode: "+err.Error()) return } if req.ContentHash == "" { writeError(w, http.StatusBadRequest, "content_hash is required") return } receipt, fresh, err := s.Backend.Submit(r.Context(), req.Envelope, req.ContentHash) switch { case errors.Is(err, backend.ErrContentHashMismatch): writeError(w, http.StatusConflict, err.Error()) return case errors.Is(err, backend.ErrTrillianUnavailable): writeError(w, http.StatusServiceUnavailable, err.Error()) return case err != nil: s.logErr("transparency-log submit: %v", err) writeError(w, http.StatusInternalServerError, "internal error") return } status := http.StatusOK if fresh { status = http.StatusCreated } writeJSON(w, status, receipt) } func (s *Server) logErr(format string, args ...any) { if s.LogError != nil { s.LogError(format, args...) } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, errorBody{Error: msg}) } func isJSONWithCharset(ct string) bool { // Accept "application/json; charset=utf-8" and friends. const prefix = "application/json" if len(ct) < len(prefix) { return false } if ct[:len(prefix)] != prefix { return false } rest := ct[len(prefix):] return len(rest) == 0 || rest[0] == ';' || rest[0] == ' ' } // bytesReader avoids pulling in bytes just for a reader over a []byte. type sliceReader struct { b []byte } func (r *sliceReader) Read(p []byte) (int, error) { if len(r.b) == 0 { return 0, io.EOF } n := copy(p, r.b) r.b = r.b[n:] return n, nil } func bytesReader(b []byte) io.Reader { return &sliceReader{b: b} }