2026-10-25 18:08:53 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 20:57:52 +00:00
|
|
|
// Handler returns an http.Handler that serves the ADR-0005 routes plus a
|
|
|
|
|
// liveness check at GET /healthz. Everything else is 404.
|
2026-10-25 18:08:53 +00:00
|
|
|
func (s *Server) Handler() http.Handler {
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit))
|
2026-10-25 20:57:52 +00:00
|
|
|
mux.Handle("GET /api/v1/log/entries/{hash}", http.HandlerFunc(s.handleGetEntry))
|
|
|
|
|
mux.Handle("GET /healthz", http.HandlerFunc(s.handleHealth))
|
2026-10-25 18:08:53 +00:00
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 20:57:52 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-25 18:08:53 +00:00
|
|
|
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} }
|