mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-23 23:55:05 +00:00
add channels api routes
This commit is contained in:
6 files changed
+455
-76
No files matched your search
+22
-7
@@ -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;
|
||||
|
||||
-- ============================================================
|
||||
|
||||
+118
-20
@@ -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
|
||||
}
|
||||
|
||||
+142
-1
@@ -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
|
||||
}
|
||||
@@ -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=<hex>
|
||||
// limit=50
|
||||
// cursor=<opaque>
|
||||
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=<epoch ms>
|
||||
// limit=50
|
||||
// cursor=<opaque>
|
||||
//
|
||||
// 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=<epoch ms>
|
||||
// limit=50
|
||||
// cursor=<opaque>
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
+57
-5
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user