server.goraw
package httpapi
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/mainstarkov/veil-browser/backend/internal/session"
)
const (
sessionCookieName = "veil_session"
maxRequestBody = 1 << 20
)
type Options struct {
AllowedOrigin string
SecureCookies bool
}
type API struct {
store *session.Store
option Options
mux *http.ServeMux
}
type responseEnvelope struct {
Data any `json:"data,omitempty"`
Meta any `json:"meta,omitempty"`
}
type errorEnvelope struct {
Error apiError `json:"error"`
Meta any `json:"meta,omitempty"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func New(store *session.Store, option Options) http.Handler {
api := &API{store: store, option: option, mux: http.NewServeMux()}
api.routes()
return api.middleware(api.mux)
}
func (api *API) routes() {
api.mux.HandleFunc("GET /health", api.health)
api.mux.HandleFunc("POST /api/v1/sessions", api.createSession)
api.mux.HandleFunc("GET /api/v1/sessions/current", api.getSession)
api.mux.HandleFunc("PUT /api/v1/sessions/current", api.updateSession)
api.mux.HandleFunc("DELETE /api/v1/sessions/current", api.deleteSession)
}
func (api *API) health(writer http.ResponseWriter, _ *http.Request) {
writeJSON(writer, http.StatusOK, responseEnvelope{Data: map[string]string{"status": "ok"}})
}
func (api *API) createSession(writer http.ResponseWriter, request *http.Request) {
if cookie, err := request.Cookie(sessionCookieName); err == nil {
if _, err := api.store.Get(cookie.Value); err == nil {
writeError(writer, request, http.StatusConflict, "SESSION_EXISTS", "session already exists")
return
}
api.clearSessionCookie(writer)
}
created, err := api.store.Create()
if err != nil {
writeError(writer, request, http.StatusInternalServerError, "INTERNAL_ERROR", "could not create session")
return
}
api.setSessionCookie(writer, created.ID, time.Until(created.ExpiresAt))
writeJSON(writer, http.StatusCreated, responseEnvelope{Data: created, Meta: requestMeta(request)})
}
func (api *API) getSession(writer http.ResponseWriter, request *http.Request) {
current, ok := api.currentSession(writer, request)
if !ok {
return
}
writeJSON(writer, http.StatusOK, responseEnvelope{Data: current, Meta: requestMeta(request)})
}
func (api *API) updateSession(writer http.ResponseWriter, request *http.Request) {
current, ok := api.currentSession(writer, request)
if !ok {
return
}
var state session.State
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, maxRequestBody))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&state); err != nil {
writeError(writer, request, http.StatusBadRequest, "INVALID_JSON", "request body is invalid")
return
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(writer, request, http.StatusBadRequest, "INVALID_JSON", "request body must contain one JSON object")
return
}
updated, err := api.store.Update(current.ID, state)
if err != nil {
if errors.Is(err, session.ErrNotFound) {
api.clearSessionCookie(writer)
writeError(writer, request, http.StatusNotFound, "SESSION_NOT_FOUND", "session does not exist or has expired")
return
}
writeError(writer, request, http.StatusBadRequest, "VALIDATION_ERROR", err.Error())
return
}
api.setSessionCookie(writer, updated.ID, time.Until(updated.ExpiresAt))
writeJSON(writer, http.StatusOK, responseEnvelope{Data: updated, Meta: requestMeta(request)})
}
func (api *API) deleteSession(writer http.ResponseWriter, request *http.Request) {
cookie, err := request.Cookie(sessionCookieName)
if err == nil {
api.store.Delete(cookie.Value)
}
api.clearSessionCookie(writer)
writer.WriteHeader(http.StatusNoContent)
}
func (api *API) currentSession(writer http.ResponseWriter, request *http.Request) (session.Session, bool) {
cookie, err := request.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
writeError(writer, request, http.StatusNotFound, "SESSION_NOT_FOUND", "session does not exist or has expired")
return session.Session{}, false
}
current, err := api.store.Get(cookie.Value)
if err != nil {
api.clearSessionCookie(writer)
writeError(writer, request, http.StatusNotFound, "SESSION_NOT_FOUND", "session does not exist or has expired")
return session.Session{}, false
}
return current, true
}
func (api *API) setSessionCookie(writer http.ResponseWriter, id string, ttl time.Duration) {
http.SetCookie(writer, &http.Cookie{
Name: sessionCookieName,
Value: id,
Path: "/api/v1/sessions",
MaxAge: max(1, int(ttl.Seconds())),
HttpOnly: true,
Secure: api.option.SecureCookies,
SameSite: http.SameSiteStrictMode,
})
}
func (api *API) clearSessionCookie(writer http.ResponseWriter) {
http.SetCookie(writer, &http.Cookie{
Name: sessionCookieName,
Path: "/api/v1/sessions",
MaxAge: -1,
HttpOnly: true,
Secure: api.option.SecureCookies,
SameSite: http.SameSiteStrictMode,
})
}
func (api *API) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestID := request.Header.Get("X-Request-ID")
if requestID == "" || len(requestID) > 128 {
requestID = newRequestID()
}
request.Header.Set("X-Request-ID", requestID)
writer.Header().Set("X-Request-ID", requestID)
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
origin := request.Header.Get("Origin")
if origin != "" {
if origin != api.option.AllowedOrigin {
writeError(writer, request, http.StatusForbidden, "ORIGIN_NOT_ALLOWED", "request origin is not allowed")
return
}
writer.Header().Set("Access-Control-Allow-Origin", origin)
writer.Header().Set("Access-Control-Allow-Credentials", "true")
writer.Header().Set("Vary", "Origin")
}
if request.Method == http.MethodOptions {
writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Request-ID")
writer.Header().Set("Access-Control-Max-Age", "600")
writer.WriteHeader(http.StatusNoContent)
return
}
if (request.Method == http.MethodPost || request.Method == http.MethodPut) &&
!strings.HasPrefix(request.Header.Get("Content-Type"), "application/json") && request.ContentLength != 0 {
writeError(writer, request, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
return
}
started := time.Now()
next.ServeHTTP(writer, request)
slog.Info("request", "id", requestID, "method", request.Method, "path", request.URL.Path, "duration", time.Since(started))
})
}
func requestMeta(request *http.Request) map[string]string {
return map[string]string{"requestId": request.Header.Get("X-Request-ID")}
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
if err := json.NewEncoder(writer).Encode(value); err != nil {
slog.Error("could not encode response", "error", err)
}
}
func writeError(writer http.ResponseWriter, request *http.Request, status int, code, message string) {
writeJSON(writer, status, errorEnvelope{
Error: apiError{Code: code, Message: message},
Meta: requestMeta(request),
})
}
func newRequestID() string {
buffer := make([]byte, 12)
if _, err := rand.Read(buffer); err != nil {
return "request-unavailable"
}
return hex.EncodeToString(buffer)
}