ProofedMesh/services/transparency-log/pkg/httpapi/server.go

136 lines
3.6 KiB
Go
Raw Normal View History

// 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. Only
// POST /api/v1/log/entries is implemented; everything else is 404.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit))
return mux
}
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} }