Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
77117f8
chore: add testify + sqlmock for new tests
SyniRon May 16, 2026
ea2456c
refactor(auth): migrate to N-scopes-per-key model
SyniRon May 16, 2026
aa63412
feat(referencecache): in-memory cache for tickets reference data
SyniRon May 16, 2026
b90f546
feat(datastores): implement referencecache.Loader on Mysql
SyniRon May 16, 2026
d4313ac
feat(server): load + refresh reference cache at startup
SyniRon May 16, 2026
b6df951
fix(referencecache): code review hardening
SyniRon May 16, 2026
1d12b3b
feat(proto): add tickets.proto for TicketsService
SyniRon May 16, 2026
d0bcb68
chore: regenerate proto + swagger + statik for tickets
SyniRon May 16, 2026
e164a42
fix(proto): add bearer auth + per-RPC openapiv2 annotations to tickets
SyniRon May 16, 2026
1c90e28
feat(xenforo): GORM models for xf_nf_tickets_* tables
SyniRon May 16, 2026
228f71c
feat(datastores): ListTickets with filters + cursor pagination
SyniRon May 16, 2026
1f377cc
feat(datastores): GetTicket + GetTicketByRef
SyniRon May 16, 2026
8bff766
feat(datastores): GetTicketFirstMessages with count
SyniRon May 16, 2026
d212f10
feat(datastores): ListTicketMessages with cursor pagination
SyniRon May 16, 2026
8bb32af
feat(datastores): ListCategories from reference cache
SyniRon May 16, 2026
471bb89
feat(grpc): TicketsService.ListTickets with scope check
SyniRon May 16, 2026
a552fd6
feat(grpc): GetTicket + GetTicketByRef handlers
SyniRon May 16, 2026
61aa892
feat(grpc): ListTicketMessages handler
SyniRon May 16, 2026
6a5923f
feat(grpc): ListCategories handler
SyniRon May 16, 2026
b03d1e2
fix(grpc): map ticket-fetch errors to gRPC status codes
SyniRon May 16, 2026
1524b92
feat(server): register TicketsService on gRPC + HTTP gateway
SyniRon May 16, 2026
08e90e4
feat(middleware): bypass Redis cache for /api/v1/tickets/*
SyniRon May 16, 2026
69b3705
chore: bump version to 2.2.0
SyniRon May 16, 2026
56f59a6
feat(auth): add shared ParseBearerToken helper
SyniRon May 17, 2026
1e664a1
refactor(grpc): use shared ParseBearerToken in auth interceptor
SyniRon May 17, 2026
0c5e953
refactor(gateway): use shared ParseBearerToken in HTTP middleware
SyniRon May 17, 2026
b80e2ff
feat(tickets): add ErrInvalidCursor sentinel
SyniRon May 17, 2026
0283af5
fix(tickets): map invalid cursor to InvalidArgument (400)
SyniRon May 17, 2026
887d43e
docs(tickets): clarify repeat-the-param semantics on ListTickets
SyniRon May 17, 2026
ce0aa19
feat(tickets): total_message_count + opaque message cursor
SyniRon May 17, 2026
0e0db63
fix(tickets): tighten cursor decoders to reject trailing garbage
SyniRon May 17, 2026
d9212d4
fix(tickets): include position=0 in ListTicketMessages first page
SyniRon May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions buf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@ lint:
ignore_only:
PACKAGE_DIRECTORY_MATCH:
- proto/milpacs.proto
- proto/tickets.proto
PACKAGE_VERSION_SUFFIX:
- proto/milpacs.proto
- proto/tickets.proto
RPC_REQUEST_RESPONSE_UNIQUE:
- proto/milpacs.proto
- proto/tickets.proto
RPC_REQUEST_STANDARD_NAME:
- proto/milpacs.proto
- proto/tickets.proto
RPC_RESPONSE_STANDARD_NAME:
- proto/milpacs.proto
- proto/tickets.proto
disallow_comment_ignores: true
breaking:
except:
Expand Down
106 changes: 106 additions & 0 deletions datastores/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package datastores

import (
"regexp"
"strings"
"testing"

"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)

func newMockDS(t *testing.T) (Mysql, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
require.NoError(t, err)
gormDB, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{})
require.NoError(t, err)
return Mysql{Db: gormDB}, mock, func() { sqlDB.Close() }
}

func TestValidateApiKey_ValidWithMultipleScopes(t *testing.T) {
ds, mock, cleanup := newMockDS(t)
defer cleanup()

rows := sqlmock.NewRows([]string{"key_id", "user_id", "scope_name"}).
AddRow(42, 1648, "read").
AddRow(42, 1648, "read:tickets")

mock.ExpectQuery(regexp.QuoteMeta("SELECT k.key_id, k.user_id, sd.scope_name")).
WithArgs("cav7_testtoken").
WillReturnRows(rows)

result, err := ds.ValidateApiKey("cav7_testtoken")
require.NoError(t, err)
require.NotNil(t, result)

assert.Equal(t, uint(42), result.KeyId)
assert.Equal(t, uint(1648), result.UserId)
assert.True(t, result.HasScope("read"))
assert.True(t, result.HasScope("read:tickets"))
assert.False(t, result.HasScope("write:something"))
}

func TestValidateApiKey_ZeroRowsReturnsNil(t *testing.T) {
ds, mock, cleanup := newMockDS(t)
defer cleanup()

rows := sqlmock.NewRows([]string{"key_id", "user_id", "scope_name"})
mock.ExpectQuery(regexp.QuoteMeta("SELECT k.key_id, k.user_id, sd.scope_name")).
WithArgs("bad_token").
WillReturnRows(rows)

result, err := ds.ValidateApiKey("bad_token")
require.NoError(t, err)
assert.Nil(t, result, "no rows must return nil result (auth boundary then responds 401)")
}

func TestValidateApiKey_QueryError(t *testing.T) {
ds, mock, cleanup := newMockDS(t)
defer cleanup()

mock.ExpectQuery(regexp.QuoteMeta("SELECT k.key_id, k.user_id, sd.scope_name")).
WithArgs("any_token").
WillReturnError(assert.AnError)

result, err := ds.ValidateApiKey("any_token")
assert.Error(t, err)
assert.Nil(t, result)
}

func TestParseBearerToken(t *testing.T) {
cases := []struct {
name string
raw string
maxLen int
want string
}{
{"canonical scheme", "Bearer abc", 128, "abc"},
{"lowercase scheme", "bearer abc", 128, "abc"},
{"uppercase scheme", "BEARER abc", 128, "abc"},
{"mixed-case scheme", "bEaReR abc", 128, "abc"},
{"basic scheme rejected", "Basic abc", 128, ""},
{"empty input", "", 128, ""},
{"whitespace only", " ", 128, ""},
{"scheme with no token", "Bearer ", 128, ""},
{"scheme with whitespace token", "Bearer ", 128, ""},
{"oversize token", "Bearer " + strings.Repeat("x", 200), 128, ""},
{"max-len boundary", "Bearer " + strings.Repeat("x", 128), 128, strings.Repeat("x", 128)},
{"one over max-len", "Bearer " + strings.Repeat("x", 129), 128, ""},
{"outer whitespace tolerated", " Bearer abc ", 128, "abc"},
{"inner extra spaces collapsed", "Bearer abc", 128, "abc"},
{"missing scheme separator", "Bearerabc", 128, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ParseBearerToken(c.raw, c.maxLen)
assert.Equal(t, c.want, got)
})
}
}
61 changes: 56 additions & 5 deletions datastores/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@
package datastores

import (
"github.com/7cav/api/proto"
"github.com/7cav/api/xenforo"
"context"
"log"
"os"
"strings"

"github.com/7cav/api/proto"
"github.com/7cav/api/referencecache"
"github.com/7cav/api/xenforo"
)

var (
Expand All @@ -32,9 +36,35 @@ var (
)

type ApiKeyResult struct {
KeyId uint
UserId uint
ScopeRead bool
KeyId uint
UserId uint
Scopes map[string]struct{}
}

// HasScope reports whether the API key has been granted the named scope.
func (r *ApiKeyResult) HasScope(name string) bool {
if r == nil {
return false
}
_, ok := r.Scopes[name]
return ok
}

// ParseBearerToken extracts the raw API token from an Authorization header
// value. Returns "" if no Bearer scheme is present, the token is empty,
// or the result exceeds maxLen. Scheme name is matched case-insensitively
// per RFC 7235.
func ParseBearerToken(raw string, maxLen int) string {
raw = strings.TrimSpace(raw)
const prefix = "Bearer "
if len(raw) < len(prefix) || !strings.EqualFold(raw[:len(prefix)], prefix) {
return ""
}
tok := strings.TrimSpace(raw[len(prefix):])
if tok == "" || len(tok) > maxLen {
return ""
}
return tok
}

type Datastore interface {
Expand All @@ -52,4 +82,25 @@ type Datastore interface {
GetTableUpdates() ([]xenforo.TableInfo, error)
FindProfileByGamertag(gamertag string) (*proto.Profile, error)
ValidateApiKey(rawKey string) (*ApiKeyResult, error)

// Tickets
ListTickets(ctx context.Context, rc TicketReferenceCache, filter *ListTicketsFilter) (tickets []*proto.Ticket, nextCursor string, hasMore bool, err error)
GetTicket(ctx context.Context, rc TicketReferenceCache, ticketID uint32, forumBaseURL string) (*proto.Ticket, error)
GetTicketByRef(ctx context.Context, rc TicketReferenceCache, ref string, forumBaseURL string) (*proto.Ticket, error)
GetTicketFirstMessages(ctx context.Context, ticketID uint32, n int, includeHidden bool) (msgs []*proto.Message, totalCount uint32, err error)
ListTicketMessages(ctx context.Context, ticketID uint32, afterCursor string, perPage uint32, includeHidden bool) (msgs []*proto.Message, nextCursor string, hasMore bool, err error)
ListCategories(ctx context.Context, rc TicketReferenceCache) ([]*proto.Category, error)
}

// TicketReferenceCache is the slice of referencecache.ReferenceCache that
// tickets datastore methods need. Defined here (not pulled in via dependency)
// so the Datastore interface stays self-describing and easy to mock in tests.
type TicketReferenceCache interface {
StatusName(id uint32) string
PriorityName(id uint32) string
PrefixName(id uint32) string
Category(id uint32) *referencecache.CategoryRecord
CategoryAncestors(id uint32) []uint32
CategoryTree() []*referencecache.CategoryRecord
ExpandSubtree(ids []uint32) []uint32
}
34 changes: 23 additions & 11 deletions datastores/mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,24 +705,36 @@ func (ds Mysql) GetTableUpdates() ([]xenforo.TableInfo, error) {
}

func (ds Mysql) ValidateApiKey(rawKey string) (*ApiKeyResult, error) {
var row struct {
KeyId uint `gorm:"column:key_id"`
UserId uint `gorm:"column:user_id"`
ScopeRead bool `gorm:"column:scope_read"`
var rows []struct {
KeyId uint `gorm:"column:key_id"`
UserId uint `gorm:"column:user_id"`
ScopeName string `gorm:"column:scope_name"`
}
tx := ds.Db.Raw(`
SELECT key_id, user_id, scope_read
FROM xf_cav7_api_key
WHERE key_hash = UNHEX(SHA2(?, 256))
AND is_active = 1`, rawKey).Scan(&row)
SELECT k.key_id, k.user_id, sd.scope_name
FROM xf_cav7_api_key k
JOIN xf_cav7_api_key_scope ks ON ks.key_id = k.key_id
JOIN xf_cav7_api_key_scope_def sd ON sd.scope_id = ks.scope_id
WHERE k.key_hash = UNHEX(SHA2(?, 256))
AND k.is_active = 1
AND sd.is_active = 1`, rawKey).Scan(&rows)
if tx.Error != nil {
return nil, tx.Error
}
if tx.RowsAffected == 0 {
if len(rows) == 0 {
return nil, nil
}
go ds.Db.Exec(`UPDATE xf_cav7_api_key SET last_used_date = UNIX_TIMESTAMP() WHERE key_id = ?`, row.KeyId)
return &ApiKeyResult{KeyId: row.KeyId, UserId: row.UserId, ScopeRead: row.ScopeRead}, nil
scopes := make(map[string]struct{}, len(rows))
for _, r := range rows {
scopes[r.ScopeName] = struct{}{}
}
keyId := rows[0].KeyId
go ds.Db.Exec(`UPDATE xf_cav7_api_key SET last_used_date = UNIX_TIMESTAMP() WHERE key_id = ?`, keyId)
return &ApiKeyResult{
KeyId: keyId,
UserId: rows[0].UserId,
Scopes: scopes,
}, nil
}

func (ds Mysql) FindProfileByGamertag(gamertag string) (*proto.Profile, error) {
Expand Down
Loading