This commit is contained in:
h4nz4
2026-03-12 20:09:16 +01:00
commit f9b7eaa20d
23 changed files with 1481 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
package httpapi
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"netisjwt/internal/jws"
"netisjwt/internal/statuslist"
"netisjwt/internal/storage"
)
type Handler struct {
store storage.Store
privatePEM []byte
now func() time.Time
}
// NewObravnalnik ustvari HTTP obravnalnik za status API.
func NewObravnalnik(store storage.Store, privatePEM []byte) *Handler {
return &Handler{
store: store,
privatePEM: privatePEM,
now: time.Now,
}
}
// obravnavajStatusRoot obdela /api/status za GET in POST.
func (h *Handler) obravnavajStatusRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
ids, err := h.store.SeznamIDjev(r.Context())
if err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
zapisiJSON(w, http.StatusOK, map[string]any{"statusIds": ids})
case http.MethodPost:
statusID, err := newStatusID()
if err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
if err := h.store.Ustvari(r.Context(), statusID, statuslist.New()); err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
zapisiJSON(w, http.StatusCreated, map[string]any{"statusId": statusID})
default:
zapisiMethodNotAllowed(w)
}
}
// obravnavajStatusPoPoti razcleni path parametre statusId in index.
func (h *Handler) obravnavajStatusPoPoti(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/status/")
path = strings.Trim(path, "/")
if path == "" {
http.NotFound(w, r)
return
}
parts := strings.Split(path, "/")
if len(parts) == 1 {
if r.Method != http.MethodPost {
zapisiMethodNotAllowed(w)
return
}
h.obravnavajDodajStatus(w, r, parts[0])
return
}
if len(parts) == 2 {
index, err := strconv.Atoi(parts[1])
if err != nil {
zapisiNapako(w, http.StatusBadRequest, errors.New("index must be integer"))
return
}
h.obravnavajIndeksiranStatus(w, r, parts[0], index)
return
}
http.NotFound(w, r)
}
// obravnavajDodajStatus doda novo stanje v status listo.
func (h *Handler) obravnavajDodajStatus(w http.ResponseWriter, r *http.Request, statusID string) {
list, err := h.store.Dobi(r.Context(), statusID)
if errors.Is(err, storage.ErrNotFound) {
zapisiNapako(w, http.StatusNotFound, err)
return
}
if err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
index := list.Dodaj(false)
if err := h.store.Posodobi(r.Context(), statusID, list); err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
zapisiJSON(w, http.StatusCreated, map[string]any{"index": index})
}
// obravnavajIndeksiranStatus vodi GET/PUT/DELETE za en status index.
func (h *Handler) obravnavajIndeksiranStatus(w http.ResponseWriter, r *http.Request, statusID string, index int) {
switch r.Method {
case http.MethodGet:
h.obravnavajDobiZeton(w, r, statusID, index)
case http.MethodPut:
h.obravnavajNastavi(w, r, statusID, index, true)
case http.MethodDelete:
h.obravnavajNastavi(w, r, statusID, index, false)
default:
zapisiMethodNotAllowed(w)
}
}
// obravnavajNastavi nastavi vrednost enega status bita.
func (h *Handler) obravnavajNastavi(w http.ResponseWriter, r *http.Request, statusID string, index int, value bool) {
list, err := h.store.Dobi(r.Context(), statusID)
if errors.Is(err, storage.ErrNotFound) {
zapisiNapako(w, http.StatusNotFound, err)
return
}
if err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
if err := list.Nastavi(index, value); err != nil {
zapisiNapako(w, http.StatusBadRequest, err)
return
}
if err := h.store.Posodobi(r.Context(), statusID, list); err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// obravnavajDobiZeton vrne podpisan JWS token za podan index.
func (h *Handler) obravnavajDobiZeton(w http.ResponseWriter, r *http.Request, statusID string, index int) {
list, err := h.store.Dobi(r.Context(), statusID)
if errors.Is(err, storage.ErrNotFound) {
zapisiNapako(w, http.StatusNotFound, err)
return
}
if err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
if _, err := list.Dobi(index); err != nil {
zapisiNapako(w, http.StatusBadRequest, err)
return
}
issuer := izdajaIzdajatelja(r, statusID)
claims, err := jws.SestaviTrditve(issuer, list, index, h.now())
if err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
token, err := jws.UstvariZetonIzPEM(h.privatePEM, claims)
if err != nil {
zapisiNapako(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(token))
}
// izdajaIzdajatelja pripravi iss vrednost za JWT trditve.
func izdajaIzdajatelja(r *http.Request, statusID string) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + "/api/status/" + statusID
}
// newStatusID ustvari kratek URL-safe identifikator seznama.
func newStatusID() (string, error) {
raw := make([]byte, 6)
if _, err := rand.Read(raw); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// zapisiMethodNotAllowed vrne standarden odgovor 405.
func zapisiMethodNotAllowed(w http.ResponseWriter) {
zapisiNapako(w, http.StatusMethodNotAllowed, errors.New("method not allowed"))
}
// zapisiNapako serializira napako v JSON odziv.
func zapisiNapako(w http.ResponseWriter, code int, err error) {
zapisiJSON(w, code, map[string]any{"error": err.Error()})
}
// zapisiJSON zapise poljuben objekt kot JSON odgovor.
func zapisiJSON(w http.ResponseWriter, code int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(payload)
}

View File

@@ -0,0 +1,125 @@
package httpapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
appcrypto "netisjwt/internal/crypto"
"netisjwt/internal/statuslist"
"netisjwt/internal/storage"
)
type mockStore struct {
data map[string]*statuslist.StatusList
}
func newMockStore() *mockStore {
return &mockStore{data: make(map[string]*statuslist.StatusList)}
}
func (m *mockStore) Ustvari(_ context.Context, statusID string, list *statuslist.StatusList) error {
m.data[statusID] = mustCloneList(list)
return nil
}
func (m *mockStore) SeznamIDjev(_ context.Context) ([]string, error) {
out := make([]string, 0, len(m.data))
for id := range m.data {
out = append(out, id)
}
return out, nil
}
func (m *mockStore) Dobi(_ context.Context, statusID string) (*statuslist.StatusList, error) {
list, ok := m.data[statusID]
if !ok {
return nil, storage.ErrNotFound
}
return mustCloneList(list), nil
}
func (m *mockStore) Posodobi(_ context.Context, statusID string, list *statuslist.StatusList) error {
if _, ok := m.data[statusID]; !ok {
return storage.ErrNotFound
}
m.data[statusID] = mustCloneList(list)
return nil
}
func mustCloneList(list *statuslist.StatusList) *statuslist.StatusList {
clone, err := statuslist.IzSurovihPodatkov(list.Bajti(), list.DolzinaBitov())
if err != nil {
panic(err)
}
return clone
}
func TestStatusFlow(t *testing.T) {
privatePEM, err := appcrypto.UstvariZasebniKljucECDSAP256PEM()
if err != nil {
t.Fatalf("key gen: %v", err)
}
store := newMockStore()
handler := NewObravnalnik(store, privatePEM)
router := NewUsmerjevalnik(handler)
statusID := createListAndReturnID(t, router)
createIndexAndSetTrue(t, router, statusID)
req := httptest.NewRequest(http.MethodGet, "/api/status/"+statusID+"/0", nil)
req.Host = "localhost:8000"
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
token := strings.TrimSpace(rec.Body.String())
if strings.Count(token, ".") != 2 {
t.Fatalf("expected compact JWS token")
}
}
func createListAndReturnID(t *testing.T, router http.Handler) string {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
var response struct {
StatusID string `json:"statusId"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("invalid json: %v", err)
}
if response.StatusID == "" {
t.Fatalf("expected non-empty statusId")
}
return response.StatusID
}
func createIndexAndSetTrue(t *testing.T, router http.Handler, statusID string) {
t.Helper()
addReq := httptest.NewRequest(http.MethodPost, "/api/status/"+statusID, nil)
addRec := httptest.NewRecorder()
router.ServeHTTP(addRec, addReq)
if addRec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", addRec.Code)
}
setReq := httptest.NewRequest(http.MethodPut, "/api/status/"+statusID+"/0", nil)
setRec := httptest.NewRecorder()
router.ServeHTTP(setRec, setReq)
if setRec.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", setRec.Code)
}
}

View File

@@ -0,0 +1,11 @@
package httpapi
import "net/http"
// NewUsmerjevalnik pripravi vse API poti za status endpointe.
func NewUsmerjevalnik(handler *Handler) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/api/status", handler.obravnavajStatusRoot)
mux.HandleFunc("/api/status/", handler.obravnavajStatusPoPoti)
return mux
}