Files
MrAlders0n ba0e85c44e fix(channels): review fixes for channel_iatas
Refresh last_heard on duplicate observations too, capped at hourly, so
steady traffic can't age a channel out of the filter while its packets
stay retained (dedup key has no heard_at). Guard the seed join against
the docker /dev/shm cap like the trace one, and drop the unused
last_heard index column so the upserts stay HOT.
2026-07-24 12:07:05 -07:00

185 lines
5.9 KiB
Go

// 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"
// @Param limit query int false "Max results (default 50)"
// @Success 200 {object} api.Page[api.ChannelSummary]
// @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) {
var limit int64 = 50
if limitParam := r.URL.Query().Get("limit"); limitParam != "" {
l, err := strconv.ParseInt(limitParam, 10, 32)
if err != nil {
respondError(w, http.StatusBadRequest, "limit must be an integer")
return
}
limit = l
}
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 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(), int32(limit), hashHex, iatas, cursor)
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)"
// @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
}
var limit int64 = 50
if limitParam := r.URL.Query().Get("limit"); limitParam != "" {
l, err := strconv.ParseInt(limitParam, 10, 32)
if err != nil {
respondError(w, http.StatusBadRequest, "limit must be an integer")
return
}
limit = l
}
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, int32(limit), iatas, scope, cursor)
if err != nil {
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
respond(w, http.StatusOK, messages)
}
}