// Copyright 2026 Beacon Contributors // SPDX-License-Identifier: AGPL-3.0-or-later package handlers import ( "encoding/hex" "net/http" "strconv" "strings" "time" "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/go-chi/chi/v5" ) // ChannelsRouter mounts all /channels routes onto a subrouter. // // GET /channels → listChannels // GET /channels/{channelID} → getChannel // GET /channels/{channelID}/messages → listChannelMessages func ChannelsRouter(reader api.Reader) http.Handler { r := chi.NewRouter() r.Get("/", listChannels(reader)) r.Route("/{channelID}", func(r chi.Router) { r.Get("/", getChannel(reader)) r.Get("/messages", listChannelMessages(reader)) }) return r } // listChannels godoc // // @Summary List channels // @Tags Channels // @Produce json // @Param hash query string false "Single-byte channel hash (hex)" // @Param iata query string false "Filter by IATA code" // @Param iatas query string false "Filter by IATA code(s), comma-separated e.g. YOW or YOW,YYZ" // @Param cursor query int false "last_seen epoch ms of last item for pagination; 0 starts from the beginning" // @Param pageCursor query string false "Opaque nextPageCursor from a previous response; preserves timestamp ties and precision. Cannot be combined with a positive cursor; cursor=0 is allowed." // @Param limit query int false "Max results (default 50); must be positive, values above 200 are clamped" minimum(1) maximum(200) // @Success 200 {object} api.ChannelPage // @Failure 400 {object} handlers.APIError // @Failure 500 {object} handlers.APIError // @Router /channels [get] func listChannels(reader api.Reader) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { limit, err := parseLimit(r, 50) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } iatas := parseIATAs(r) var cursor int64 if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { c, err := strconv.ParseInt(cursorParam, 10, 64) if err != nil { respondError(w, http.StatusBadRequest, "cursor must be an integer") return } cursor = c } var pageCursor *api.ChannelCursor if raw := r.URL.Query().Get("pageCursor"); raw != "" { if cursor > 0 { respondError(w, http.StatusBadRequest, "pageCursor and a positive cursor cannot be combined") return } pageCursor, err = api.ParseChannelCursor(raw) if err != nil { respondError(w, http.StatusBadRequest, "invalid pageCursor") return } } var hashHex []byte if hash := strings.ToLower(r.URL.Query().Get("hash")); hash != "" { h, decodeErr := hex.DecodeString(hash) if decodeErr != nil { respondError(w, http.StatusBadRequest, "invalid channel hash") return } if len(h) != 1 { respondError(w, http.StatusBadRequest, "hash must be a single hex byte") return } hashHex = h } channels, err := reader.ListChannels(r.Context(), limit, hashHex, iatas, cursor, pageCursor) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return } respond(w, http.StatusOK, channels) } } // getChannel godoc // // @Summary Get channel detail // @Tags Channels // @Produce json // @Param channelID path int true "Channel integer ID" // @Success 200 {object} api.Channel // @Failure 400 {object} handlers.APIError // @Failure 404 {object} handlers.APIError // @Router /channels/{channelID} [get] func getChannel(reader api.Reader) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var id int64 if channelID := chi.URLParam(r, "channelID"); channelID != "" { i, err := strconv.ParseInt(channelID, 10, 32) if err != nil { respondError(w, http.StatusBadRequest, "channelID should be an int 32") return } id = i } channel, err := reader.GetChannel(r.Context(), int32(id)) if err != nil { respondError(w, http.StatusNotFound, "channel not found") return } respond(w, http.StatusOK, channel) } } // listChannelMessages godoc // // @Summary List messages for a channel // @Tags Channels // @Produce json // @Param channelID path int true "Channel integer ID" // @Param since query int false "Return messages after this epoch ms" // @Param iatas query string false "Filter by IATA code(s), comma-separated e.g. YVR or YVR,YYJ" // @Param regionId query int false "Filter by region ID, expands to member IATAs" // @Param region query string false "Filter by region slug, expands to member IATAs" // @Param scope query string false "Filter by transport scope name e.g. %23bc (URL-encoded #bc)" // @Param cursor query int false "Message ID of last item for pagination (results ordered newest first)" // @Param limit query int false "Max results (default 50); must be positive, values above 200 are clamped" minimum(1) maximum(200) // @Success 200 {object} object // @Failure 400 {object} handlers.APIError // @Failure 500 {object} handlers.APIError // @Router /channels/{channelID}/messages [get] func listChannelMessages(reader api.Reader) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var id int64 if channelID := chi.URLParam(r, "channelID"); channelID != "" { i, err := strconv.ParseInt(channelID, 10, 32) if err != nil { respondError(w, http.StatusBadRequest, "channelID should be an int 32") return } id = i } limit, err := parseLimit(r, 50) if err != nil { respondError(w, http.StatusBadRequest, err.Error()) return } var since time.Time if sinceParam := r.URL.Query().Get("since"); sinceParam != "" { ms, err := strconv.ParseInt(sinceParam, 10, 64) if err != nil { respondError(w, http.StatusBadRequest, "since must be epoch milliseconds") return } since = time.UnixMilli(ms) } iatas := parseIATAs(r) var cursor int64 if cursorParam := r.URL.Query().Get("cursor"); cursorParam != "" { c, err := strconv.ParseInt(cursorParam, 10, 64) if err != nil { respondError(w, http.StatusBadRequest, "cursor must be an integer") return } cursor = c } scope := r.URL.Query().Get("scope") chanID := int32(id) messages, err := reader.ListChannelMessages(r.Context(), &chanID, since, limit, iatas, scope, cursor) if err != nil { respondError(w, http.StatusInternalServerError, "internal server error") return } respond(w, http.StatusOK, messages) } }