Files
Piik/internal/server/app/routes.go
T
AAA da47189fc3 refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
2026-09-06 23:26:01 +08:00

443 lines
14 KiB
Go

package app
import (
"errors"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/TNTcraftHIM/Screener/internal/server/config"
"github.com/TNTcraftHIM/Screener/internal/server/protocol"
"github.com/TNTcraftHIM/Screener/internal/server/room"
"github.com/TNTcraftHIM/Screener/internal/server/signal"
)
// The request router of src/server/app.ts. Route order is a contract (map
// section 4.3, O34): the first match wins and the /api/ catch-all sits after
// every concrete route.
var (
roomReplacementPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/replacement$`)
roomAccessPath = regexp.MustCompile(`^/api/rooms/([1-9][0-9]{3})/access$`)
)
// ServeHTTP is the createServer request listener of app.ts.
func (s *Server) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
recorder := &responseRecorder{ResponseWriter: writer}
defer func() {
recovered := recover()
if recovered == nil {
return
}
if recovered == http.ErrAbortHandler {
panic(recovered)
}
// TS: console.error("HTTP request failed", {method, path, error}).
// D11 keeps the method and the path only.
s.requestFailed(recorder, request)
}()
if !s.acceptingTraffic.Load() {
// Hazard 13: no traffic at all until the SignalingServer exists, so
// /healthz answers 503 while LiveKit reconciliation runs.
recorder.Header().Set("Cache-Control", "no-store")
recorder.Header().Set("Retry-After", "1")
sendJSON(recorder, http.StatusServiceUnavailable, errorBody{"Service starting"})
return
}
signaling := s.signaling.Load()
if isUpgradeRequest(request) {
// Node routed every upgrade to the SignalingServer's "upgrade"
// listener, which was attached only while traffic was accepted.
signaling.ServeHTTP(recorder, request)
return
}
s.route(recorder, request, signaling)
}
func (s *Server) route(
writer http.ResponseWriter,
request *http.Request,
signaling *signal.Server,
) {
path := requestPath(request)
if path == "/healthz" {
writer.Header().Set("Cache-Control", "no-store")
if !allowMethod(writer, request, http.MethodGet) {
return
}
sendJSON(writer, http.StatusOK, healthBody{"ok"})
return
}
if path == "/api/capabilities" {
noStoreJSON(writer)
if !allowMethod(writer, request, http.MethodGet) {
return
}
sendJSON(writer, http.StatusOK, protocol.RuntimeCapabilities{
NatPrediction: s.config.NATPredictionEnabled,
})
return
}
if path == "/api/site-access" {
s.handleSiteAccess(writer, request)
return
}
if match := roomReplacementPath.FindStringSubmatch(path); match != nil {
s.handleRoomReplacement(writer, request, signaling, match[1])
return
}
if match := roomAccessPath.FindStringSubmatch(path); match != nil {
s.handleRoomAccess(writer, request, signaling, match[1])
return
}
if path == "/api/rooms" {
s.handleRoomCreation(writer, request, signaling)
return
}
if strings.HasPrefix(path, "/api/") {
// The catch-all sets no nosniff header; only the concrete routes do.
writer.Header().Set("Cache-Control", "no-store")
sendJSON(writer, http.StatusNotFound, errorBody{"Not found"})
return
}
if s.frontend != nil {
s.frontend.ServeHTTP(writer, request)
return
}
notFoundJSON(writer, request)
}
// notFoundJSON is the fallthrough app.ts handed to sirv as its next()
// callback and used when no frontend is composed at all.
func notFoundJSON(writer http.ResponseWriter, _ *http.Request) {
sendJSON(writer, http.StatusNotFound, errorBody{"Not found"})
}
// noStoreJSON sets the two headers every /api route sets before its method
// check, so a 405 carries them too.
func noStoreJSON(writer http.ResponseWriter) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Content-Type-Options", "nosniff")
}
// allowMethod answers the 405 row of the HTTP table, Allow header included.
func allowMethod(writer http.ResponseWriter, request *http.Request, allowed string) bool {
if request.Method == allowed {
return true
}
writer.Header().Set("Allow", allowed)
sendJSON(writer, http.StatusMethodNotAllowed, errorBody{"Method not allowed"})
return false
}
// handleSiteAccess is handleSiteAccessRequest of app.ts.
func (s *Server) handleSiteAccess(writer http.ResponseWriter, request *http.Request) {
noStoreJSON(writer)
if request.Method == http.MethodGet {
status := siteAccessBody{
Required: s.siteAccess.required(),
Authenticated: s.siteAccess.isAuthenticated(cookieHeader(request)),
}
if status.Required && status.Authenticated {
// The Set-Cookie value is written verbatim, in the TS attribute
// order (D11).
writer.Header().Set("Set-Cookie", s.siteAccess.createCookie())
}
sendJSON(writer, http.StatusOK, status)
return
}
if request.Method != http.MethodPost {
writer.Header().Set("Allow", "GET, POST")
sendJSON(writer, http.StatusMethodNotAllowed, errorBody{"Method not allowed"})
return
}
if !s.allowedRequestOrigin(request) {
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
return
}
if hasRequestBody(request) {
sendJSON(writer, http.StatusBadRequest, errorBody{"Request body is not accepted"})
return
}
if !s.siteAccess.required() {
sendJSON(writer, http.StatusOK, siteAccessBody{Required: false, Authenticated: true})
return
}
if provided := bearerToken(request); provided == "" || !s.siteAccess.passwordMatches(provided) {
writer.Header().Set("WWW-Authenticate", "Bearer")
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
return
}
writer.Header().Set("Set-Cookie", s.siteAccess.createCookie())
sendJSON(writer, http.StatusOK, siteAccessBody{Required: true, Authenticated: true})
}
func (s *Server) handleRoomReplacement(
writer http.ResponseWriter,
request *http.Request,
signaling *signal.Server,
roomID string,
) {
noStoreJSON(writer)
if !allowMethod(writer, request, http.MethodPost) {
return
}
hostToken, authorized := s.authorizedRoomHostToken(writer, request)
if !authorized {
return
}
body, err := readJSONBody(request)
var parsed protocol.ReplaceRoomRequest
if err == nil {
parsed, err = protocol.DecodeReplaceRoomRequest(body)
}
if err != nil {
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room replacement request"})
return
}
if signaling == nil {
sendJSON(writer, http.StatusServiceUnavailable, errorBody{"Service unavailable"})
return
}
created, err := signaling.ReplaceRoom(
roomID,
hostToken,
parsed.CodeEntryPolicy,
parsed.RoomPassword.Value,
)
if err != nil {
if status, message, mapped := replacementFailure(err); mapped {
sendJSON(writer, status, errorBody{message})
return
}
s.requestFailed(writer, request)
return
}
sendJSON(writer, http.StatusCreated, s.createRoomResponse(created))
}
func (s *Server) handleRoomAccess(
writer http.ResponseWriter,
request *http.Request,
signaling *signal.Server,
roomID string,
) {
noStoreJSON(writer)
if !allowMethod(writer, request, http.MethodPost) {
return
}
hostToken, authorized := s.authorizedRoomHostToken(writer, request)
if !authorized {
return
}
body, err := readJSONBody(request)
var parsed protocol.RoomAccessUpdateRequest
if err == nil {
parsed, err = protocol.DecodeRoomAccessUpdateRequest(body)
}
if err != nil {
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room access request"})
return
}
if signaling == nil {
sendJSON(writer, http.StatusServiceUnavailable, errorBody{"Service unavailable"})
return
}
updated, err := signaling.UpdateRoomAccess(roomID, hostToken, parsed)
if err != nil {
if status, message, mapped := accessFailure(err); mapped {
sendJSON(writer, status, errorBody{message})
return
}
s.requestFailed(writer, request)
return
}
sendJSON(writer, http.StatusOK, updated)
}
func (s *Server) handleRoomCreation(
writer http.ResponseWriter,
request *http.Request,
signaling *signal.Server,
) {
noStoreJSON(writer)
if !allowMethod(writer, request, http.MethodPost) {
return
}
if !s.allowedRequestOrigin(request) {
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
return
}
// Hazard 12: the cookie is the only room-creation credential. A
// `Bearer <site password>` header must still be 401.
if !s.roomCreationAuthorized(request) {
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
return
}
body, err := readJSONBody(request)
var parsed protocol.CreateRoomRequest
if err == nil {
parsed, err = protocol.DecodeCreateRoomRequest(body)
}
if err != nil {
sendJSON(writer, http.StatusBadRequest, errorBody{"Invalid room request"})
return
}
// TS called roomStore.createRoom directly; the store is now guarded by the
// signaling server's global mutex, so creation goes through it. There is
// no `signaling == nil` answer here, because app.ts had no getSignaling()
// check on this route: reconcile stores the signaling server before it
// sets acceptingTraffic and never clears it again, so reaching a handler
// at all means the pointer is set (the two sibling room routes keep their
// 503 only because the TypeScript had one).
created, err := signaling.CreateRoom(
parsed.CodeEntryPolicy,
parsed.RoomPassword.Value,
parsed.PreferredRoomID,
)
if err != nil {
if status, message, mapped := creationFailure(err); mapped {
sendJSON(writer, status, errorBody{message})
return
}
s.requestFailed(writer, request)
return
}
sendJSON(writer, http.StatusCreated, s.createRoomResponse(created))
}
// authorizedRoomHostToken of app.ts: origin, then site access, then the bearer
// Host token, each with its own terminal response.
func (s *Server) authorizedRoomHostToken(
writer http.ResponseWriter,
request *http.Request,
) (string, bool) {
if !s.allowedRequestOrigin(request) {
sendJSON(writer, http.StatusForbidden, errorBody{"Forbidden"})
return "", false
}
if !s.roomCreationAuthorized(request) {
sendJSON(writer, http.StatusUnauthorized, errorBody{"Unauthorized"})
return "", false
}
hostToken := bearerToken(request)
if hostToken == "" {
// An absent Host token is indistinguishable from a wrong one.
sendJSON(writer, http.StatusNotFound, errorBody{"Room not found"})
return "", false
}
return hostToken, true
}
// allowedRequestOrigin ports isStrictlyAllowedRequestOrigin: the header must be
// present, must parse, must be exactly its own serialised origin (so
// "http://allowed.test/path" is refused) and must be configured.
func (s *Server) allowedRequestOrigin(request *http.Request) bool {
origin := request.Header.Get("Origin")
if origin == "" {
return false
}
parsed, err := url.Parse(origin)
if err != nil || config.Origin(parsed) != origin {
return false
}
_, allowed := s.config.AllowedOrigins[origin]
return allowed
}
// roomCreationAuthorized ports isRoomCreationAuthorized.
func (s *Server) roomCreationAuthorized(request *http.Request) bool {
return !s.siteAccess.required() || s.siteAccess.isAuthenticated(cookieHeader(request))
}
// createRoomResponse of app.ts. signal.InviteURL is the one owner of the invite
// URL shape; the rotated-grant notification on the signaling side uses it too.
func (s *Server) createRoomResponse(created room.CreatedRoom) protocol.CreateRoomResponse {
return protocol.CreateRoomResponse{
RoomID: created.RoomID,
HostToken: created.HostToken,
InviteURL: signal.InviteURL(s.config.PublicBaseURL, created.RoomID, created.ViewerGrant),
CodeEntryPolicy: created.CodeEntryPolicy,
ExpiresAt: created.ExpiresAt,
// The lease is configured in whole seconds; TS divided by 1000 into a
// JS number and the response schema declares an integer.
RoomLeaseSeconds: protocol.Int(s.config.RoomLeaseMs / 1_000),
}
}
// requestFailed is the createServer .catch: log the method and the path only —
// never a body, header, token or grant (D11) — then answer 500 unless the
// response already started, where Node destroyed the socket.
func (s *Server) requestFailed(writer http.ResponseWriter, request *http.Request) {
s.logger.Error("HTTP request failed",
"method", request.Method, "path", requestPath(request))
if recorder, ok := writer.(*responseRecorder); ok && recorder.wrote {
panic(http.ErrAbortHandler)
}
sendJSON(writer, http.StatusInternalServerError, errorBody{"Internal server error"})
}
// roomErrorCode returns the RoomStoreError code, or "" when err is not one.
func roomErrorCode(err error) room.ErrorCode {
var roomError *room.Error
if errors.As(err, &roomError) {
return roomError.Code
}
return ""
}
// replacementFailure maps replaceRoom errors; an unmapped error rethrows into
// the 500 handler, as the TS `throw error` did.
func replacementFailure(err error) (int, string, bool) {
switch roomErrorCode(err) {
case room.CodeRoomLimit:
return http.StatusServiceUnavailable, "Room capacity reached", true
case room.CodeRoomAccessDenied, room.CodeRoomBusy:
return http.StatusServiceUnavailable, "Room replacement unavailable", true
case room.CodeInvalidToken, room.CodeRoomNotFound, room.CodeRoomExpired:
return http.StatusNotFound, "Room not found", true
}
return 0, "", false
}
// accessFailure maps updateRoomAccess errors. Unlike the other two tables,
// every remaining RoomStoreError is a 404; only a non-store error rethrows.
func accessFailure(err error) (int, string, bool) {
switch code := roomErrorCode(err); code {
case "":
return 0, "", false
case room.CodeRoomBusy:
return http.StatusServiceUnavailable, "Room access update unavailable", true
case room.CodeRoomAccessDenied:
return http.StatusConflict, "Room access update rejected", true
default:
return http.StatusNotFound, "Room not found", true
}
}
// creationFailure maps createRoom errors.
func creationFailure(err error) (int, string, bool) {
switch roomErrorCode(err) {
case room.CodeRoomLimit:
return http.StatusServiceUnavailable, "Room capacity reached", true
case room.CodeRoomBusy:
return http.StatusServiceUnavailable, "Room creation unavailable", true
case room.CodeInvalidToken:
return http.StatusBadRequest, "Invalid room request", true
}
return 0, "", false
}