diff --git a/db/queries/queries.sql b/db/queries/queries.sql index ea02609..29900b1 100644 --- a/db/queries/queries.sql +++ b/db/queries/queries.sql @@ -245,7 +245,7 @@ SELECT * FROM channels ORDER BY last_seen DESC LIMIT $1; -- name: GetChannelsByHash :many -- Returns all channels for a given hash (may be multiple on hash collision). -SELECT * FROM channels WHERE channel_hash = $1 ORDER BY last_seen DESC; +SELECT * FROM channels WHERE channel_hash = $1 ORDER BY last_seen DESC LIMIT $2; -- name: GetChannelByHashAndFingerprint :one SELECT * FROM channels WHERE channel_hash = $1 AND key_fingerprint = $2; @@ -253,20 +253,35 @@ SELECT * FROM channels WHERE channel_hash = $1 AND key_fingerprint = $2; -- name: GetChannelByHashtag :one SELECT * FROM channels WHERE hashtag = $1; +-- name: GetChannelByID :one +SELECT * FROM channels WHERE id = $1; + + -- ============================================================ -- CHANNEL MESSAGES -- ============================================================ -- name: InsertChannelMessage :exec -INSERT INTO channel_messages (channel_id, packet_hash, sender_name, sender_pubkey, content, sent_at) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO channel_messages (channel_id, packet_hash, sender_name, content, sent_at) +VALUES ($1, $2, $3, $4, $5) ON CONFLICT (packet_hash) DO NOTHING; -- name: ListChannelMessages :many -SELECT * FROM channel_messages -WHERE channel_id = $1 - AND ($2::timestamptz IS NULL OR sent_at >= $2) -ORDER BY sent_at DESC +SELECT cm.*, encode(p.packet_hash, 'hex') as packet_hash_hex, c.channel_hash +FROM channel_messages cm +JOIN packets p ON p.packet_hash = cm.packet_hash +JOIN channels c ON c.id = cm.channel_id +WHERE cm.channel_id = $1 + AND ($2::timestamptz IS NULL OR cm.sent_at >= $2) +ORDER BY cm.sent_at DESC +LIMIT $3; + +-- name: ListChannelMessagesByHash :many +SELECT cm.*, c.channel_hash FROM channel_messages cm +JOIN channels c ON c.id = cm.channel_id +WHERE c.channel_hash = $1 + AND ($2::timestamptz IS NULL OR cm.sent_at >= $2) +ORDER BY cm.sent_at DESC LIMIT $3; -- ============================================================ diff --git a/db/sqlc/queries.sql.go b/db/sqlc/queries.sql.go index 7e42f30..eb9beab 100644 --- a/db/sqlc/queries.sql.go +++ b/db/sqlc/queries.sql.go @@ -63,13 +63,41 @@ func (q *Queries) GetChannelByHashtag(ctx context.Context, hashtag *string) (Cha return i, err } -const getChannelsByHash = `-- name: GetChannelsByHash :many -SELECT id, channel_hash, key_fingerprint, name, hashtag, is_hashtag, is_public, key_known, first_seen, last_seen, message_count FROM channels WHERE channel_hash = $1 ORDER BY last_seen DESC +const getChannelByID = `-- name: GetChannelByID :one +SELECT id, channel_hash, key_fingerprint, name, hashtag, is_hashtag, is_public, key_known, first_seen, last_seen, message_count FROM channels WHERE id = $1 ` +func (q *Queries) GetChannelByID(ctx context.Context, id int32) (Channel, error) { + row := q.db.QueryRow(ctx, getChannelByID, id) + var i Channel + err := row.Scan( + &i.ID, + &i.ChannelHash, + &i.KeyFingerprint, + &i.Name, + &i.Hashtag, + &i.IsHashtag, + &i.IsPublic, + &i.KeyKnown, + &i.FirstSeen, + &i.LastSeen, + &i.MessageCount, + ) + return i, err +} + +const getChannelsByHash = `-- name: GetChannelsByHash :many +SELECT id, channel_hash, key_fingerprint, name, hashtag, is_hashtag, is_public, key_known, first_seen, last_seen, message_count FROM channels WHERE channel_hash = $1 ORDER BY last_seen DESC LIMIT $2 +` + +type GetChannelsByHashParams struct { + ChannelHash []byte `json:"channel_hash"` + Limit int32 `json:"limit"` +} + // Returns all channels for a given hash (may be multiple on hash collision). -func (q *Queries) GetChannelsByHash(ctx context.Context, channelHash []byte) ([]Channel, error) { - rows, err := q.db.Query(ctx, getChannelsByHash, channelHash) +func (q *Queries) GetChannelsByHash(ctx context.Context, arg GetChannelsByHashParams) ([]Channel, error) { + rows, err := q.db.Query(ctx, getChannelsByHash, arg.ChannelHash, arg.Limit) if err != nil { return nil, err } @@ -411,18 +439,17 @@ func (q *Queries) GetTopNodes(ctx context.Context, arg GetTopNodesParams) ([]MvT const insertChannelMessage = `-- name: InsertChannelMessage :exec -INSERT INTO channel_messages (channel_id, packet_hash, sender_name, sender_pubkey, content, sent_at) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO channel_messages (channel_id, packet_hash, sender_name, content, sent_at) +VALUES ($1, $2, $3, $4, $5) ON CONFLICT (packet_hash) DO NOTHING ` type InsertChannelMessageParams struct { - ChannelID int32 `json:"channel_id"` - PacketHash []byte `json:"packet_hash"` - SenderName *string `json:"sender_name"` - SenderPubkey []byte `json:"sender_pubkey"` - Content *string `json:"content"` - SentAt pgtype.Timestamptz `json:"sent_at"` + ChannelID int32 `json:"channel_id"` + PacketHash []byte `json:"packet_hash"` + SenderName *string `json:"sender_name"` + Content *string `json:"content"` + SentAt pgtype.Timestamptz `json:"sent_at"` } // ============================================================ @@ -433,7 +460,6 @@ func (q *Queries) InsertChannelMessage(ctx context.Context, arg InsertChannelMes arg.ChannelID, arg.PacketHash, arg.SenderName, - arg.SenderPubkey, arg.Content, arg.SentAt, ) @@ -531,10 +557,13 @@ func (q *Queries) InsertObservation(ctx context.Context, arg InsertObservationPa } const listChannelMessages = `-- name: ListChannelMessages :many -SELECT id, channel_id, packet_hash, sender_name, sender_pubkey, content, sent_at FROM channel_messages -WHERE channel_id = $1 - AND ($2::timestamptz IS NULL OR sent_at >= $2) -ORDER BY sent_at DESC +SELECT cm.id, cm.channel_id, cm.packet_hash, cm.sender_name, cm.sender_pubkey, cm.content, cm.sent_at, encode(p.packet_hash, 'hex') as packet_hash_hex, c.channel_hash +FROM channel_messages cm +JOIN packets p ON p.packet_hash = cm.packet_hash +JOIN channels c ON c.id = cm.channel_id +WHERE cm.channel_id = $1 + AND ($2::timestamptz IS NULL OR cm.sent_at >= $2) +ORDER BY cm.sent_at DESC LIMIT $3 ` @@ -544,15 +573,27 @@ type ListChannelMessagesParams struct { Limit int32 `json:"limit"` } -func (q *Queries) ListChannelMessages(ctx context.Context, arg ListChannelMessagesParams) ([]ChannelMessage, error) { +type ListChannelMessagesRow struct { + ID int64 `json:"id"` + ChannelID int32 `json:"channel_id"` + PacketHash []byte `json:"packet_hash"` + SenderName *string `json:"sender_name"` + SenderPubkey []byte `json:"sender_pubkey"` + Content *string `json:"content"` + SentAt pgtype.Timestamptz `json:"sent_at"` + PacketHashHex string `json:"packet_hash_hex"` + ChannelHash []byte `json:"channel_hash"` +} + +func (q *Queries) ListChannelMessages(ctx context.Context, arg ListChannelMessagesParams) ([]ListChannelMessagesRow, error) { rows, err := q.db.Query(ctx, listChannelMessages, arg.ChannelID, arg.Column2, arg.Limit) if err != nil { return nil, err } defer rows.Close() - items := []ChannelMessage{} + items := []ListChannelMessagesRow{} for rows.Next() { - var i ChannelMessage + var i ListChannelMessagesRow if err := rows.Scan( &i.ID, &i.ChannelID, @@ -561,6 +602,63 @@ func (q *Queries) ListChannelMessages(ctx context.Context, arg ListChannelMessag &i.SenderPubkey, &i.Content, &i.SentAt, + &i.PacketHashHex, + &i.ChannelHash, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listChannelMessagesByHash = `-- name: ListChannelMessagesByHash :many +SELECT cm.id, cm.channel_id, cm.packet_hash, cm.sender_name, cm.sender_pubkey, cm.content, cm.sent_at, c.channel_hash FROM channel_messages cm +JOIN channels c ON c.id = cm.channel_id +WHERE c.channel_hash = $1 + AND ($2::timestamptz IS NULL OR cm.sent_at >= $2) +ORDER BY cm.sent_at DESC +LIMIT $3 +` + +type ListChannelMessagesByHashParams struct { + ChannelHash []byte `json:"channel_hash"` + Column2 pgtype.Timestamptz `json:"column_2"` + Limit int32 `json:"limit"` +} + +type ListChannelMessagesByHashRow struct { + ID int64 `json:"id"` + ChannelID int32 `json:"channel_id"` + PacketHash []byte `json:"packet_hash"` + SenderName *string `json:"sender_name"` + SenderPubkey []byte `json:"sender_pubkey"` + Content *string `json:"content"` + SentAt pgtype.Timestamptz `json:"sent_at"` + ChannelHash []byte `json:"channel_hash"` +} + +func (q *Queries) ListChannelMessagesByHash(ctx context.Context, arg ListChannelMessagesByHashParams) ([]ListChannelMessagesByHashRow, error) { + rows, err := q.db.Query(ctx, listChannelMessagesByHash, arg.ChannelHash, arg.Column2, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListChannelMessagesByHashRow{} + for rows.Next() { + var i ListChannelMessagesByHashRow + if err := rows.Scan( + &i.ID, + &i.ChannelID, + &i.PacketHash, + &i.SenderName, + &i.SenderPubkey, + &i.Content, + &i.SentAt, + &i.ChannelHash, ); err != nil { return nil, err } diff --git a/db/store.go b/db/store.go index 3fcaf24..7da812b 100644 --- a/db/store.go +++ b/db/store.go @@ -6,7 +6,9 @@ package db import ( "context" "encoding/binary" + "encoding/hex" "errors" + "time" sqlc "github.com/MeshCore-Tower/tower-server/db/sqlc" "github.com/MeshCore-Tower/tower-server/internal/api" @@ -32,7 +34,11 @@ func (s *Store) UpsertObserver(ctx context.Context, pubkey []byte) (uuid.UUID, s if err != nil { return uuid.Nil, "", err } - return row.ID, *row.DisplayName, err + displayName := "" + if row.DisplayName != nil { + displayName = *row.DisplayName + } + return row.ID, displayName, err } // UpsertObserverBroker records that this observer was seen on brokerName. @@ -348,3 +354,138 @@ func (s *Store) UpsertRegionIATA(ctx context.Context, regionID int32, iata strin Iata: iata, }) } + +// ListChannels returns a summary list of all known channels ordered by last seen. +// Includes both hashtag-derived and explicit key channels. +// Channels with unknown keys are included with KeyKnown=false. +// Filters on hash if provided, this is the hex channel hash +func (s *Store) ListChannels(ctx context.Context, limit int32, hash []byte) ([]api.ChannelSummary, error) { + var rows []sqlc.Channel + if hash != nil { + r, err := s.q.GetChannelsByHash(ctx, sqlc.GetChannelsByHashParams{ + ChannelHash: hash, + Limit: limit, + }) + if err != nil { + return nil, err + } + rows = r + } else { + r, err := s.q.ListChannels(ctx, limit) + if err != nil { + return nil, err + } + rows = r + } + channels := make([]api.ChannelSummary, 0, len(rows)) + for _, v := range rows { + channels = append(channels, api.ChannelSummary{ + ID: int(v.ID), + Name: v.Name, + ChannelHash: hex.EncodeToString(v.ChannelHash), + LastSeen: v.LastSeen.Time.Format(time.RFC3339), + IsHashtag: v.IsHashtag != nil && *v.IsHashtag, + KeyKnown: v.KeyKnown != nil && *v.KeyKnown, + }) + } + + return channels, nil +} + +// GetChannel returns full detail for a single channel by its integer ID. +// Returns nil, pgx.ErrNoRows if the channel is not found. +func (s *Store) GetChannel(ctx context.Context, channelID int32) (*api.Channel, error) { + row, err := s.q.GetChannelByID(ctx, channelID) + if err != nil { + return nil, err + } + channel := api.Channel{ + ChannelSummary: api.ChannelSummary{ + ID: int(row.ID), + Name: row.Name, + ChannelHash: hex.EncodeToString(row.ChannelHash), + LastSeen: row.LastSeen.Time.Format(time.RFC3339), + IsHashtag: row.IsHashtag != nil && *row.IsHashtag, + KeyKnown: row.KeyKnown != nil && *row.KeyKnown, + }, + Hashtag: row.Hashtag, + MessageCount: 0, + } + if row.MessageCount != nil { + channel.MessageCount = *row.MessageCount + } + if row.IsHashtag != nil && *row.IsHashtag && row.KeyFingerprint != nil { + fp := hex.EncodeToString(row.KeyFingerprint) + channel.KeyFingerprint = &fp + } + return &channel, nil +} + +// ListChannelMessages returns paginated messages for a channel identified by its integer ID. +// Used by the /channels/{id}/messages endpoint. +// Pass a zero time.Time for since to return all messages up to limit. +func (s *Store) ListChannelMessages(ctx context.Context, channelID int32, since time.Time, limit int32) ([]api.ChannelMessage, error) { + rows, err := s.q.ListChannelMessages(ctx, sqlc.ListChannelMessagesParams{ + ChannelID: channelID, + Column2: pgtype.Timestamptz{Time: since, Valid: !since.IsZero()}, + Limit: limit, + }) + if err != nil { + return nil, err + } + messages := make([]api.ChannelMessage, 0, len(rows)) + for _, v := range rows { + senderName := "" + if v.SenderName != nil { + senderName = *v.SenderName + } + content := "" + if v.Content != nil { + content = *v.Content + } + messages = append(messages, api.ChannelMessage{ + ID: v.ID, + PacketHash: v.PacketHashHex, + ChannelHash: hex.EncodeToString(v.ChannelHash), + SenderName: senderName, + Content: content, + SentAt: v.SentAt.Time.Format(time.RFC3339), + }) + } + return messages, nil +} + +// ListChannelMessagesByHash returns paginated messages for all channels matching the given hash. +// Used by the /messages?hash= endpoint. May return messages from multiple channels +// if the hash collides across different keys. +// Pass a zero time.Time for since to return all messages up to limit. +func (s *Store) ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32) ([]api.ChannelMessage, error) { + rows, err := s.q.ListChannelMessagesByHash(ctx, sqlc.ListChannelMessagesByHashParams{ + ChannelHash: hash, + Column2: pgtype.Timestamptz{Time: since, Valid: !since.IsZero()}, + Limit: limit, + }) + if err != nil { + return nil, err + } + messages := make([]api.ChannelMessage, 0, len(rows)) + for _, v := range rows { + senderName := "" + if v.SenderName != nil { + senderName = *v.SenderName + } + content := "" + if v.Content != nil { + content = *v.Content + } + messages = append(messages, api.ChannelMessage{ + ID: v.ID, + ChannelHash: hex.EncodeToString(v.ChannelHash), + PacketHash: hex.EncodeToString(v.PacketHash), + SenderName: senderName, + Content: content, + SentAt: v.SentAt.Time.Format(time.RFC3339), + }) + } + return messages, nil +} diff --git a/internal/api/handlers/channels.go b/internal/api/handlers/channels.go index e814f0a..2046119 100644 --- a/internal/api/handlers/channels.go +++ b/internal/api/handlers/channels.go @@ -1,59 +1,132 @@ package handlers import ( + "encoding/hex" "net/http" + "strconv" + "time" + "github.com/MeshCore-Tower/tower-server/internal/api" "github.com/go-chi/chi/v5" ) // ChannelsRouter mounts all /channels routes onto a subrouter. // -// GET /channels → ListChannels -// GET /channels/{channelHash} → GetChannel -// GET /channels/{channelHash}/messages → ListChannelMessages -func ChannelsRouter() http.Handler { +// 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) + // ListChannels handles GET /api/v1/channels + // + // Query params (all optional): + // + // hash= + // limit=50 + // cursor= + r.Get("/", 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 { + respond(w, http.StatusBadRequest, map[string]string{"error": "limit must be an integer"}) + return + } else { + limit = l + } + } + var channels []api.ChannelSummary + var err error + if hash := r.URL.Query().Get("hash"); hash != "" { + hashHex, decodeErr := hex.DecodeString(hash) + if decodeErr != nil { + respond(w, http.StatusBadRequest, map[string]string{"error": "invalid channel hash"}) + return + } + if len(hashHex) != 1 { + respond(w, http.StatusBadRequest, map[string]string{"error": "hash must be a single hex byte"}) + return + } + channels, err = reader.ListChannels(r.Context(), int32(limit), hashHex) + } else { + channels, err = reader.ListChannels(r.Context(), int32(limit), nil) + } + if err != nil { + respond(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + return + } + respond(w, http.StatusOK, channels) + }) - r.Route("/{channelHash}", func(r chi.Router) { - r.Get("/", GetChannel) - r.Get("/messages", ListChannelMessages) + r.Route("/{channelID}", func(r chi.Router) { + // GetChannel handles GET /api/v1/channels/{channelID} + // + // Returns channel detail including key for hashtag channels and message count. + // Other channel keys are server-side config; key material is never exposed via the API. + r.Get("/", 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 { + respond(w, http.StatusBadRequest, map[string]string{"error": "channelID should be an int 32"}) + return + } + id = i + } + channel, err := reader.GetChannel(r.Context(), int32(id)) + if err != nil { + respond(w, http.StatusNotFound, map[string]string{"error": "channel not found"}) + return + } + respond(w, http.StatusOK, channel) + }) + // ListChannelMessages handles GET /api/v1/channels/{channelID}/messages + // + // Query params (all optional): + // + // since= + // limit=50 + // cursor= + // + // Returns paginated decrypted channel messages. + r.Get("/messages", 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 { + respond(w, http.StatusBadRequest, map[string]string{"error": "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 { + respond(w, http.StatusBadRequest, map[string]string{"error": "limit must be an integer"}) + return + } else { + limit = l + } + } + var since time.Time + if sinceParam := r.URL.Query().Get("since"); sinceParam != "" { + ms, err := strconv.ParseInt(sinceParam, 10, 64) + if err != nil { + respond(w, http.StatusBadRequest, map[string]string{"error": "since must be epoch milliseconds"}) + return + } + since = time.UnixMilli(ms) + } + messages, err := reader.ListChannelMessages(r.Context(), int32(id), since, int32(limit)) + if err != nil { + respond(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + return + } + respond(w, http.StatusOK, messages) + }) }) return r } - -// ListChannels handles GET /api/v1/channels -// -// Query params (all optional): -// limit=50 -func ListChannels(w http.ResponseWriter, r *http.Request) { - // TODO: query channels ORDER BY last_seen DESC, write JSON response. - w.WriteHeader(http.StatusNotImplemented) -} - -// GetChannel handles GET /api/v1/channels/{channelHash} -// -// Returns channel detail including key_known status and message count. -// Channel keys are server-side config; key material is never exposed via the API. -func GetChannel(w http.ResponseWriter, r *http.Request) { - // channelHash := chi.URLParam(r, "channelHash") - // TODO: fetch channel, write JSON response. - w.WriteHeader(http.StatusNotImplemented) -} - -// ListChannelMessages handles GET /api/v1/channels/{channelHash}/messages -// -// Query params (all optional): -// since= -// limit=50 -// cursor= -// -// Returns paginated decrypted channel messages. Messages where key_known=false -// will have content=null. -func ListChannelMessages(w http.ResponseWriter, r *http.Request) { - // channelHash := chi.URLParam(r, "channelHash") - // TODO: fetch channel_messages, paginate, write JSON response. - w.WriteHeader(http.StatusNotImplemented) -} diff --git a/internal/api/reader.go b/internal/api/reader.go index 983661d..ddced6b 100644 --- a/internal/api/reader.go +++ b/internal/api/reader.go @@ -1,22 +1,58 @@ +// Package api defines the response types and read interface for the Tower REST API. package api import ( "context" + "time" ) +// ChannelMessage represents a single decrypted channel message. +// Only messages for channels with a known key are stored and returned. +type ChannelMessage struct { + ID int64 `json:"id"` + PacketHash string `json:"packetHash"` // hex-encoded packet hash for correlation with packet events + ChannelHash string `json:"channelHash"` // hex-encoded single-byte channel hash + SenderName string `json:"senderName"` // display name from the decrypted payload + Content string `json:"content"` // decrypted message text + SentAt string `json:"sentAt"` // RFC3339 timestamp from the packet payload +} + +// ChannelSummary is the minimal channel representation used in list responses. +type ChannelSummary struct { + ID int `json:"id"` + Name *string `json:"name,omitempty"` // display name, nil if not set + ChannelHash string `json:"channelHash"` // hex-encoded single-byte hash + LastSeen string `json:"lastSeen"` // ISO 8601 timestamp + IsHashtag bool `json:"isHashtag"` // true if derived from a hashtag PSK + KeyKnown bool `json:"keyKnown"` // true if Tower has a decryption key +} + +// Channel is the full channel representation including decryption metadata. +// KeyFingerprint is only populated for hashtag channels since their keys are +// publicly derivable from the tag name. +type Channel struct { + ChannelSummary + Hashtag *string `json:"hashtag,omitempty"` // tag name without # prefix + KeyFingerprint *string `json:"keyFingerprint,omitempty"` // first 8 bytes of SHA256(key), hex-encoded + MessageCount int64 `json:"messageCount"` +} + +// RegionSummary is the minimal region representation used in list responses. type RegionSummary struct { ID int `json:"id"` - Slug string `json:"slug"` + Slug string `json:"slug"` // URL-safe identifier e.g. "western-canada" Name string `json:"name"` } +// Region is the full region representation including map display hints and +// the list of IATA codes that are members of this region. type Region struct { RegionSummary Description *string `json:"description,omitempty"` - CenterLat *float64 `json:"centerLat,omitempty"` - CenterLng *float64 `json:"centerLng,omitempty"` - ZoomLevel *int `json:"zoomLevel,omitempty"` - IATAs []string `json:"iatas"` + CenterLat *float64 `json:"centerLat,omitempty"` // map center latitude + CenterLng *float64 `json:"centerLng,omitempty"` // map center longitude + ZoomLevel *int `json:"zoomLevel,omitempty"` // suggested map zoom level + IATAs []string `json:"iatas"` // member IATA codes } // IATA represents a known airport/location code used to group observers and packets. @@ -42,4 +78,20 @@ type Reader interface { // GetRegion returns full detail for a single region including its associated IATA codes. // Returns nil, pgx.ErrNoRows if the region is not found. GetRegion(ctx context.Context, regionID int32) (*Region, error) + // ListChannels returns a summary list of all known channels ordered by last seen. + // Includes both hashtag-derived and explicit key channels. + // Channels with unknown keys are included with KeyKnown=false. + ListChannels(ctx context.Context, limit int32, hash []byte) ([]ChannelSummary, error) + // GetChannel returns full detail for a single channel by its integer ID. + // Returns nil, pgx.ErrNoRows if the channel is not found. + GetChannel(ctx context.Context, channelID int32) (*Channel, error) + // ListChannelMessages returns paginated messages for a channel identified by its integer ID. + // Used by the /channels/{id}/messages endpoint. + // Pass a zero time.Time for since to return all messages up to limit. + ListChannelMessages(ctx context.Context, channelID int32, since time.Time, limit int32) ([]ChannelMessage, error) + // ListChannelMessagesByHash returns paginated messages for all channels matching the given hash. + // Used by the /messages?hash= endpoint. May return messages from multiple channels + // if the hash collides across different keys. + // Pass a zero time.Time for since to return all messages up to limit. + ListChannelMessagesByHash(ctx context.Context, hash []byte, since time.Time, limit int32) ([]ChannelMessage, error) } diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 37926c6..bdae71f 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -50,7 +50,7 @@ func New(h *hub.Hub, reader api.Reader) http.Handler { r.Mount("/packets", handlers.PacketsRouter()) r.Mount("/nodes", handlers.NodesRouter()) r.Mount("/observers", handlers.ObserversRouter()) - r.Mount("/channels", handlers.ChannelsRouter()) + r.Mount("/channels", handlers.ChannelsRouter(reader)) r.Mount("/iatas", handlers.IATAsRouter(reader)) r.Mount("/regions", handlers.RegionsRouter(reader)) r.Mount("/stats", handlers.StatsRouter())