This commit is contained in:
@@ -16,7 +16,8 @@ func main() {
|
||||
|
||||
app := cmds.NewApp()
|
||||
app.Commands = []*cli.Command{
|
||||
cmds.NewServeCommand(),
|
||||
cmds.NewWebCommand(),
|
||||
cmds.NewRpcCommand(),
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
|
||||
5
go.mod
5
go.mod
@@ -3,7 +3,7 @@ module git.itzana.me/strafesnet/dev-portal
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
git.itzana.me/strafesnet/go-grpc v0.0.0-20250622012600-01ac9fdf02d8
|
||||
git.itzana.me/strafesnet/go-grpc v0.0.0-20250622145054-0c0eb0ba26c7
|
||||
git.itzana.me/strafesnet/utils v0.0.0-20220716194944-d8ca164052f9
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
@@ -16,9 +16,11 @@ require (
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
@@ -42,6 +44,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/redis/go-redis/v9 v9.10.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -2,6 +2,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
git.itzana.me/strafesnet/go-grpc v0.0.0-20250622012600-01ac9fdf02d8 h1:QHlKFYqiNb9Rp5mqDMlQ05uVsVs5p52rJhqtwfybVrg=
|
||||
git.itzana.me/strafesnet/go-grpc v0.0.0-20250622012600-01ac9fdf02d8/go.mod h1:X7XTRUScRkBWq8q8bplbeso105RPDlnY7J6Wy1IwBMs=
|
||||
git.itzana.me/strafesnet/go-grpc v0.0.0-20250622145054-0c0eb0ba26c7 h1:k6Skqr00NOo9Do9Z5rxqzRSR+1BR/bY93+Lf86QlFV8=
|
||||
git.itzana.me/strafesnet/go-grpc v0.0.0-20250622145054-0c0eb0ba26c7/go.mod h1:X7XTRUScRkBWq8q8bplbeso105RPDlnY7J6Wy1IwBMs=
|
||||
git.itzana.me/strafesnet/utils v0.0.0-20220716194944-d8ca164052f9 h1:7lU6jyR7S7Rhh1dnUp7GyIRHUTBXZagw8F4n4hOyxLw=
|
||||
git.itzana.me/strafesnet/utils v0.0.0-20220716194944-d8ca164052f9/go.mod h1:uyYerSieEt4v0MJCdPLppG0LtJ4Yj035vuTetWGsxjY=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
@@ -12,6 +14,8 @@ github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3z
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
@@ -29,6 +33,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
|
||||
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
@@ -121,6 +127,8 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
|
||||
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
|
||||
59
pkg/api/middleware/validate.go
Normal file
59
pkg/api/middleware/validate.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"git.itzana.me/strafesnet/go-grpc/dev"
|
||||
"github.com/gin-gonic/gin"
|
||||
"google.golang.org/grpc"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
HeaderAPIKey = "X-API-Key"
|
||||
HeaderRateLimitBurst = "X-Rate-Limit-Burst"
|
||||
HeaderRateLimitDaily = "X-Rate-Limit-Daily"
|
||||
HeaderRateLimitMonthly = "X-Rate-Limit-Monthly"
|
||||
|
||||
// Error messages
|
||||
ErrMsgAPIKeyRequired = "API key is required"
|
||||
ErrMsgInternalServer = "Internal server error"
|
||||
)
|
||||
|
||||
// ValidateRequest returns a middleware that validates API requests against the dev service
|
||||
func ValidateRequest(service, permission string, conn *grpc.ClientConn) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
// Get API key from the request header
|
||||
apiKey := ctx.GetHeader(HeaderAPIKey)
|
||||
if apiKey == "" {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": ErrMsgAPIKeyRequired})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Create client and validate the API key
|
||||
resp, err := dev.NewDevServiceClient(conn).Validate(ctx, &dev.APIValidationRequest{
|
||||
Service: service,
|
||||
Permission: permission,
|
||||
Key: apiKey,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": ErrMsgInternalServer})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Valid {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": resp.ErrorMessage})
|
||||
ctx.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Set the remaining rate limits in the response headers
|
||||
ctx.Header(HeaderRateLimitBurst, strconv.FormatUint(resp.RemainingBurst, 10))
|
||||
ctx.Header(HeaderRateLimitDaily, strconv.FormatUint(resp.RemainingDaily, 10))
|
||||
ctx.Header(HeaderRateLimitMonthly, strconv.FormatUint(resp.RemainingMonthly, 10))
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
81
pkg/cache/cache.go
vendored
Normal file
81
pkg/cache/cache.go
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"time"
|
||||
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/model"
|
||||
)
|
||||
|
||||
// Cache is a simple caching interface for Redis
|
||||
type Cache struct {
|
||||
client *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
// NewCache creates a new Redis cache client
|
||||
func NewCache(client *redis.Client, prefix string) *Cache {
|
||||
return &Cache{
|
||||
client: client,
|
||||
prefix: prefix,
|
||||
}
|
||||
}
|
||||
|
||||
// GetApplicationByAPIKey retrieves an application from cache by API key
|
||||
func (c *Cache) GetApplicationByAPIKey(ctx context.Context, apiKey string) (*model.ApplicationCache, error) {
|
||||
key := c.getAppAPIKeyCache(apiKey)
|
||||
|
||||
val, err := c.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
return nil, fmt.Errorf("application with API key %s not found in cache", apiKey)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get from cache: %w", err)
|
||||
}
|
||||
|
||||
var app model.ApplicationCache
|
||||
if err := json.Unmarshal([]byte(val), &app); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal application: %w", err)
|
||||
}
|
||||
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// SetApplicationByAPIKey caches an application with the given API key and expiration
|
||||
func (c *Cache) SetApplicationByAPIKey(ctx context.Context, app *model.ApplicationCache, expiration time.Duration) error {
|
||||
if app == nil || app.APIKey == "" {
|
||||
return fmt.Errorf("invalid application or missing API key")
|
||||
}
|
||||
|
||||
key := c.getAppAPIKeyCache(app.APIKey)
|
||||
|
||||
data, err := json.Marshal(app)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal application: %w", err)
|
||||
}
|
||||
|
||||
if err := c.client.Set(ctx, key, data, expiration).Err(); err != nil {
|
||||
return fmt.Errorf("failed to set in cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteApplicationByAPIKey removes an application from cache by API key
|
||||
func (c *Cache) DeleteApplicationByAPIKey(ctx context.Context, apiKey string) error {
|
||||
key := c.getAppAPIKeyCache(apiKey)
|
||||
|
||||
if err := c.client.Del(ctx, key).Err(); err != nil {
|
||||
return fmt.Errorf("failed to delete from cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAppAPIKeyCache generates the cache key for an application by API key
|
||||
func (c *Cache) getAppAPIKeyCache(apiKey string) string {
|
||||
return fmt.Sprintf("%s:app:apikey:%s", c.prefix, apiKey)
|
||||
}
|
||||
@@ -24,6 +24,48 @@ var (
|
||||
return nil
|
||||
},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pg-host",
|
||||
Usage: "Host of postgres database",
|
||||
EnvVars: []string{"PG_HOST"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "pg-port",
|
||||
Usage: "Port of postgres database",
|
||||
EnvVars: []string{"PG_PORT"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pg-db",
|
||||
Usage: "Name of database to connect to",
|
||||
EnvVars: []string{"PG_DB"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pg-user",
|
||||
Usage: "User to connect with",
|
||||
EnvVars: []string{"PG_USER"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pg-password",
|
||||
Usage: "Password to connect with",
|
||||
EnvVars: []string{"PG_PASSWORD"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migrate",
|
||||
Usage: "Run database migrations",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATE"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Usage: "Port to listen on",
|
||||
Value: 8080,
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
75
pkg/cmds/rpc.go
Normal file
75
pkg/cmds/rpc.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/cache"
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/datastore/gormstore"
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/ratelimit"
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/rpc"
|
||||
"git.itzana.me/strafesnet/go-grpc/dev"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/urfave/cli/v2"
|
||||
"google.golang.org/grpc"
|
||||
"net"
|
||||
)
|
||||
|
||||
func NewRpcCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "rpc",
|
||||
Usage: "Run dev rpc service",
|
||||
Action: rpcServer,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "redis-addr",
|
||||
Usage: "Address of redis database",
|
||||
EnvVars: []string{"REDIS_ADDR"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "redis-pass",
|
||||
Usage: "Password of redis database",
|
||||
EnvVars: []string{"REDIS_PASS"},
|
||||
Required: false,
|
||||
Value: "",
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "redis-db",
|
||||
Usage: "Number of database to connect to",
|
||||
EnvVars: []string{"REDIS_DB"},
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func rpcServer(ctx *cli.Context) error {
|
||||
// Setup database
|
||||
store, err := gormstore.New(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Redis setup
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: ctx.String("redis-addr"),
|
||||
Password: ctx.String("redis-pass"),
|
||||
DB: ctx.Int("redis-db"),
|
||||
})
|
||||
|
||||
// grpc server
|
||||
ls, err := net.Listen("tcp", fmt.Sprintf(":%d", ctx.Int("port")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
|
||||
dev.RegisterDevServiceServer(grpcServer, &rpc.Dev{
|
||||
Store: store,
|
||||
Limiter: ratelimit.New(rdb),
|
||||
Cache: cache.NewCache(rdb, "app"),
|
||||
})
|
||||
|
||||
return grpcServer.Serve(ls)
|
||||
|
||||
}
|
||||
@@ -10,54 +10,12 @@ import (
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
func NewServeCommand() *cli.Command {
|
||||
func NewWebCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "serve",
|
||||
Usage: "Run dev service",
|
||||
Action: rpcServer,
|
||||
Name: "web",
|
||||
Usage: "Run dev web service",
|
||||
Action: runWeb,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "pg-host",
|
||||
Usage: "Host of postgres database",
|
||||
EnvVars: []string{"PG_HOST"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "pg-port",
|
||||
Usage: "Port of postgres database",
|
||||
EnvVars: []string{"PG_PORT"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pg-db",
|
||||
Usage: "Name of database to connect to",
|
||||
EnvVars: []string{"PG_DB"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pg-user",
|
||||
Usage: "User to connect with",
|
||||
EnvVars: []string{"PG_USER"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "pg-password",
|
||||
Usage: "Password to connect with",
|
||||
EnvVars: []string{"PG_PASSWORD"},
|
||||
Required: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migrate",
|
||||
Usage: "Run database migrations",
|
||||
Value: true,
|
||||
EnvVars: []string{"MIGRATE"},
|
||||
},
|
||||
&cli.IntFlag{
|
||||
Name: "port",
|
||||
Usage: "Port to listen on",
|
||||
Value: 8080,
|
||||
EnvVars: []string{"PORT"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "auth-rpc-host",
|
||||
Usage: "Host of auth rpc",
|
||||
@@ -74,7 +32,7 @@ func NewServeCommand() *cli.Command {
|
||||
}
|
||||
}
|
||||
|
||||
func rpcServer(ctx *cli.Context) error {
|
||||
func runWeb(ctx *cli.Context) error {
|
||||
// Setup database
|
||||
store, err := gormstore.New(ctx)
|
||||
if err != nil {
|
||||
@@ -41,6 +41,7 @@ type Datastore interface {
|
||||
// Application
|
||||
CreateApplication(ctx context.Context, app *model.Application) error
|
||||
GetApplication(ctx context.Context, id uint32) (*model.Application, error)
|
||||
GetApplicationByAPIKey(ctx context.Context, apiKey string) (*model.Application, error)
|
||||
GetAllApplications(ctx context.Context) ([]model.Application, error)
|
||||
GetApplicationsForUser(ctx context.Context, userID uint64) ([]model.Application, error)
|
||||
UpdateApplication(ctx context.Context, app *model.Application) error
|
||||
|
||||
@@ -24,6 +24,21 @@ func (g *Gormstore) GetApplication(ctx context.Context, id uint32) (*model.Appli
|
||||
return &application, result.Error
|
||||
}
|
||||
|
||||
// GetApplicationByAPIKey retrieves a single Application by its API key.
|
||||
func (g *Gormstore) GetApplicationByAPIKey(ctx context.Context, apiKey string) (*model.Application, error) {
|
||||
var application model.Application
|
||||
result := g.db.WithContext(ctx).
|
||||
Where("api_key = ?", apiKey).
|
||||
Preload("User").
|
||||
Preload("Permissions").
|
||||
First(&application)
|
||||
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, datastore.ErrNotExists
|
||||
}
|
||||
return &application, result.Error
|
||||
}
|
||||
|
||||
// GetAllApplications retrieves all Applications from the database.
|
||||
func (g *Gormstore) GetAllApplications(ctx context.Context) ([]model.Application, error) {
|
||||
var applications []model.Application
|
||||
|
||||
@@ -24,7 +24,7 @@ func GetDefaultPermissionDefinitions() []model.Permission {
|
||||
PermissionName: "Read",
|
||||
Title: "Read Data",
|
||||
Description: "Provides access to view moderation logs",
|
||||
IsDefault: true,
|
||||
IsDefault: false,
|
||||
},
|
||||
{
|
||||
Service: "Moderation",
|
||||
|
||||
@@ -17,3 +17,12 @@ type Application struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ApplicationCache struct {
|
||||
Name string `json:"name"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
Permissions []Permission `json:"permissions"`
|
||||
Active bool `json:"active"`
|
||||
RateLimit RateLimit `json:"rate_limit"`
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ type Permission struct {
|
||||
ID uint32 `gorm:"primaryKey" json:"id"`
|
||||
Service string `gorm:"size:100;uniqueIndex:idx_service_permission" json:"service"`
|
||||
PermissionName string `gorm:"size:100;uniqueIndex:idx_service_permission" json:"permission_name"`
|
||||
Title string `gorm:"size:100" json:"title"`
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
92
pkg/ratelimit/lua.go
Normal file
92
pkg/ratelimit/lua.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package ratelimit
|
||||
|
||||
const (
|
||||
// combinedRateLimitScript checks burst, daily and monthly limits in one call
|
||||
// KEYS[1] - The burst bucket key
|
||||
// KEYS[2] - The daily quota key
|
||||
// KEYS[3] - The monthly quota key
|
||||
// ARGV[1] - Current timestamp in nanoseconds
|
||||
// ARGV[2] - Cutoff timestamp for old tokens
|
||||
// ARGV[3] - Maximum burst tokens allowed
|
||||
// ARGV[4] - Burst expiration time in seconds
|
||||
// ARGV[5] - Daily maximum requests
|
||||
// ARGV[6] - Daily expiration time in seconds
|
||||
// ARGV[7] - Monthly maximum requests
|
||||
// ARGV[8] - Monthly expiration time in seconds
|
||||
combinedRateLimitScript = `
|
||||
-- Check burst limit (leaky bucket)
|
||||
local burstKey = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local cutoff = tonumber(ARGV[2])
|
||||
local maxBurst = tonumber(ARGV[3])
|
||||
local burstExpiration = tonumber(ARGV[4])
|
||||
|
||||
-- Remove tokens older than the cutoff time
|
||||
redis.call('ZREMRANGEBYSCORE', burstKey, '0', cutoff)
|
||||
-- Count current tokens in the bucket
|
||||
local burstCount = redis.call('ZCARD', burstKey)
|
||||
local remainingBurst = maxBurst - burstCount
|
||||
local burstAllowed = remainingBurst > 0
|
||||
|
||||
-- Check daily quota
|
||||
local dailyKey = KEYS[2]
|
||||
local maxDaily = tonumber(ARGV[5])
|
||||
local dailyExpiration = tonumber(ARGV[6])
|
||||
local dailyCount = redis.call('GET', dailyKey)
|
||||
local dailyUsed = 0
|
||||
if dailyCount then
|
||||
dailyUsed = tonumber(dailyCount)
|
||||
end
|
||||
local remainingDaily = maxDaily - dailyUsed
|
||||
local dailyAllowed = remainingDaily > 0
|
||||
|
||||
-- Check monthly quota
|
||||
local monthlyKey = KEYS[3]
|
||||
local maxMonthly = tonumber(ARGV[7])
|
||||
local monthlyExpiration = tonumber(ARGV[8])
|
||||
local monthlyCount = redis.call('GET', monthlyKey)
|
||||
local monthlyUsed = 0
|
||||
if monthlyCount then
|
||||
monthlyUsed = tonumber(monthlyCount)
|
||||
end
|
||||
local remainingMonthly = maxMonthly - monthlyUsed
|
||||
local monthlyAllowed = remainingMonthly > 0
|
||||
|
||||
-- Overall allowed if all checks pass
|
||||
local allowed = burstAllowed and dailyAllowed and monthlyAllowed
|
||||
|
||||
-- If allowed, update all counters
|
||||
if allowed then
|
||||
-- Update burst bucket
|
||||
redis.call('ZADD', burstKey, now, now)
|
||||
redis.call('EXPIRE', burstKey, burstExpiration)
|
||||
|
||||
-- Update daily quota
|
||||
if dailyCount then
|
||||
redis.call('INCR', dailyKey)
|
||||
else
|
||||
redis.call('SETEX', dailyKey, dailyExpiration, 1)
|
||||
end
|
||||
|
||||
-- Update monthly quota
|
||||
if monthlyCount then
|
||||
redis.call('INCR', monthlyKey)
|
||||
else
|
||||
redis.call('SETEX', monthlyKey, monthlyExpiration, 1)
|
||||
end
|
||||
|
||||
-- Decrement the remaining counts to account for this request
|
||||
remainingBurst = remainingBurst - 1
|
||||
remainingDaily = remainingDaily - 1
|
||||
remainingMonthly = remainingMonthly - 1
|
||||
end
|
||||
|
||||
-- Return array with allowed flag and remaining counts
|
||||
return {
|
||||
allowed and 1 or 0,
|
||||
remainingBurst,
|
||||
remainingDaily,
|
||||
remainingMonthly
|
||||
}
|
||||
`
|
||||
)
|
||||
124
pkg/ratelimit/rate_limit.go
Normal file
124
pkg/ratelimit/rate_limit.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const (
|
||||
// Key prefixes
|
||||
prefixLeakyBucket = "leaky"
|
||||
prefixQuota = "quota"
|
||||
|
||||
// Period identifiers
|
||||
periodDaily = "daily"
|
||||
periodMonthly = "monthly"
|
||||
|
||||
// Time constants
|
||||
dailyHours = 24
|
||||
monthlyHours = 30 * 24
|
||||
bufferTime = 60 // Additional expiration buffer in seconds
|
||||
|
||||
// Script responses
|
||||
scriptAllowed = 1
|
||||
)
|
||||
|
||||
// RateLimitConfig defines all rate limit thresholds
|
||||
type RateLimitConfig struct {
|
||||
BurstLimit uint64 // Maximum burst requests
|
||||
BurstDurationSeconds uint64 // Duration for burst window in seconds
|
||||
DailyLimit uint64 // Maximum daily requests
|
||||
MonthlyLimit uint64 // Maximum monthly requests
|
||||
}
|
||||
|
||||
// RateLimitStatus contains information about remaining limits
|
||||
type RateLimitStatus struct {
|
||||
Allowed bool // Whether the current request is allowed
|
||||
RemainingBurst uint64 // Remaining burst requests
|
||||
RemainingDaily uint64 // Remaining daily requests
|
||||
RemainingMonthly uint64 // Remaining monthly requests
|
||||
}
|
||||
|
||||
// RateLimit provides rate limiting functionality using Redis.
|
||||
type RateLimit struct {
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
// New creates a new RateLimit instance.
|
||||
func New(redisClient *redis.Client) *RateLimit {
|
||||
return &RateLimit{
|
||||
redis: redisClient,
|
||||
}
|
||||
}
|
||||
|
||||
// LeakyBucketOptions defines options for the leaky bucket algorithm.
|
||||
type LeakyBucketOptions struct {
|
||||
MaxTokens int // Maximum number of tokens in the bucket
|
||||
Interval time.Duration // Time interval for token replenishment
|
||||
}
|
||||
|
||||
// QuotaOptions defines options for quota-based rate limiting.
|
||||
type QuotaOptions struct {
|
||||
MaxRequests int // Maximum number of requests allowed
|
||||
Period time.Duration // Time period for the quota
|
||||
}
|
||||
|
||||
// CheckRateLimits checks all rate limits in a single call and returns remaining counts
|
||||
// Returns a RateLimitStatus with the allowed flag and remaining counts for all limits
|
||||
func (rl *RateLimit) CheckRateLimits(ctx context.Context, user uint64, config RateLimitConfig) (*RateLimitStatus, error) {
|
||||
now := time.Now().UnixNano()
|
||||
burstKey := formatKey(prefixLeakyBucket, user)
|
||||
dailyKey := formatKey(fmt.Sprintf("%s:%s", prefixQuota, periodDaily), user)
|
||||
monthlyKey := formatKey(fmt.Sprintf("%s:%s", prefixQuota, periodMonthly), user)
|
||||
|
||||
// Calculate parameters
|
||||
burstDuration := time.Duration(config.BurstDurationSeconds) * time.Second
|
||||
cutoff := now - burstDuration.Nanoseconds()
|
||||
burstExpiration := int(burstDuration.Seconds()) + bufferTime
|
||||
dailyExpiration := int(dailyHours * time.Hour.Seconds())
|
||||
monthlyExpiration := int(monthlyHours * time.Hour.Seconds())
|
||||
|
||||
// Execute the Lua script
|
||||
result, err := rl.redis.Eval(ctx, combinedRateLimitScript,
|
||||
[]string{burstKey, dailyKey, monthlyKey},
|
||||
now, // ARGV[1] - Current timestamp
|
||||
cutoff, // ARGV[2] - Cutoff timestamp
|
||||
config.BurstLimit, // ARGV[3] - Burst limit
|
||||
burstExpiration, // ARGV[4] - Burst expiration
|
||||
config.DailyLimit, // ARGV[5] - Daily limit
|
||||
dailyExpiration, // ARGV[6] - Daily expiration
|
||||
config.MonthlyLimit, // ARGV[7] - Monthly limit
|
||||
monthlyExpiration, // ARGV[8] - Monthly expiration
|
||||
).Result()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute rate limit script: %w", err)
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
values, ok := result.([]interface{})
|
||||
if !ok || len(values) != 4 {
|
||||
return nil, fmt.Errorf("unexpected response format from rate limit script")
|
||||
}
|
||||
|
||||
// Convert values to uint64
|
||||
allowed := values[0].(int64) == scriptAllowed
|
||||
remainingBurst, _ := values[1].(int64)
|
||||
remainingDaily, _ := values[2].(int64)
|
||||
remainingMonthly, _ := values[3].(int64)
|
||||
|
||||
return &RateLimitStatus{
|
||||
Allowed: allowed,
|
||||
RemainingBurst: uint64(remainingBurst),
|
||||
RemainingDaily: uint64(remainingDaily),
|
||||
RemainingMonthly: uint64(remainingMonthly),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// formatKey creates a consistent format for Redis keys
|
||||
func formatKey(prefix string, key uint64) string {
|
||||
return fmt.Sprintf("%s:%d", prefix, key)
|
||||
}
|
||||
195
pkg/rpc/dev.go
Normal file
195
pkg/rpc/dev.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package rpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/cache"
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/datastore"
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/model"
|
||||
"git.itzana.me/strafesnet/dev-portal/pkg/ratelimit"
|
||||
"git.itzana.me/strafesnet/go-grpc/dev"
|
||||
)
|
||||
|
||||
// Error messages
|
||||
const (
|
||||
ErrEmptyAPIKey = "empty API key"
|
||||
ErrEmptyService = "empty service name"
|
||||
ErrEmptyPermission = "empty permission name"
|
||||
ErrAppInactive = "application is inactive"
|
||||
ErrRateLimitExceeded = "rate limit exceeded"
|
||||
ErrUnauthorized = "unauthorized request"
|
||||
)
|
||||
|
||||
// Cache settings
|
||||
const (
|
||||
ApplicationCacheDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Dev implements the DevServiceServer interface
|
||||
type Dev struct {
|
||||
*dev.UnimplementedDevServiceServer
|
||||
Store datastore.Datastore
|
||||
Limiter *ratelimit.RateLimit
|
||||
Cache *cache.Cache
|
||||
}
|
||||
|
||||
// Validate checks if an API key is valid and has the required permissions
|
||||
func (d Dev) Validate(ctx context.Context, request *dev.APIValidationRequest) (*dev.APIValidationResponse, error) {
|
||||
// Check for context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return errorResponse(fmt.Errorf("context error: %w", ctx.Err()), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Validate request parameters
|
||||
if err := d.validateRequestParams(request); err != nil {
|
||||
return errorResponse(err, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Get application data
|
||||
appCache, err := d.getApplicationData(ctx, request.Key)
|
||||
if err != nil {
|
||||
return errorResponse(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Check if application is active
|
||||
if !appCache.Active {
|
||||
return buildResponse(appCache, &ratelimit.RateLimitStatus{}, false, ErrAppInactive, http.StatusForbidden), nil
|
||||
}
|
||||
|
||||
// Check rate limits
|
||||
limit, err := d.checkRateLimits(ctx, appCache)
|
||||
if err != nil {
|
||||
return buildResponse(appCache, createDefaultRateLimitStatus(), false,
|
||||
fmt.Sprintf("failed to check rate limits: %v", err), http.StatusInternalServerError), nil
|
||||
}
|
||||
|
||||
// Rate limit exceeded
|
||||
if !limit.Allowed {
|
||||
return buildResponse(appCache, limit, false, ErrRateLimitExceeded, http.StatusTooManyRequests), nil
|
||||
}
|
||||
|
||||
// Check permission
|
||||
if hasRequiredPermission(appCache.Permissions, request.Service, request.Permission) {
|
||||
return buildResponse(appCache, limit, true, "", 0), nil
|
||||
}
|
||||
|
||||
return buildResponse(appCache, limit, false, ErrUnauthorized, http.StatusUnauthorized), nil
|
||||
}
|
||||
|
||||
// validateRequestParams validates the basic parameters of the request
|
||||
func (d Dev) validateRequestParams(request *dev.APIValidationRequest) error {
|
||||
if request.Key == "" {
|
||||
return fmt.Errorf(ErrEmptyAPIKey)
|
||||
}
|
||||
if request.Service == "" {
|
||||
return fmt.Errorf(ErrEmptyService)
|
||||
}
|
||||
if request.Permission == "" {
|
||||
return fmt.Errorf(ErrEmptyPermission)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkRateLimits checks if the application has exceeded its rate limits
|
||||
func (d Dev) checkRateLimits(ctx context.Context, appCache *model.ApplicationCache) (*ratelimit.RateLimitStatus, error) {
|
||||
rateLimitConfig := ratelimit.RateLimitConfig{
|
||||
BurstLimit: appCache.RateLimit.BurstLimit,
|
||||
BurstDurationSeconds: appCache.RateLimit.BurstDuration,
|
||||
DailyLimit: appCache.RateLimit.DailyLimit,
|
||||
MonthlyLimit: appCache.RateLimit.MonthlyLimit,
|
||||
}
|
||||
|
||||
return d.Limiter.CheckRateLimits(ctx, appCache.UserID, rateLimitConfig)
|
||||
}
|
||||
|
||||
// createDefaultRateLimitStatus creates a default rate limit status with all limits at zero
|
||||
func createDefaultRateLimitStatus() *ratelimit.RateLimitStatus {
|
||||
return &ratelimit.RateLimitStatus{
|
||||
Allowed: false,
|
||||
RemainingBurst: 0,
|
||||
RemainingDaily: 0,
|
||||
RemainingMonthly: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// getApplicationData retrieves application data from cache or datastore
|
||||
func (d Dev) getApplicationData(ctx context.Context, apiKey string) (*model.ApplicationCache, error) {
|
||||
// Check for context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("context error: %w", ctx.Err())
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
appCache, err := d.Cache.GetApplicationByAPIKey(ctx, apiKey)
|
||||
if err == nil {
|
||||
return appCache, nil
|
||||
}
|
||||
|
||||
// Not in cache, retrieve from store
|
||||
app, err := d.Store.GetApplicationByAPIKey(ctx, apiKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve application: %w", err)
|
||||
}
|
||||
|
||||
user, err := d.Store.GetUser(ctx, app.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve user: %w", err)
|
||||
}
|
||||
|
||||
appCache = &model.ApplicationCache{
|
||||
Name: app.Name,
|
||||
UserID: app.UserID,
|
||||
APIKey: app.APIKey,
|
||||
Permissions: app.Permissions,
|
||||
Active: app.Active,
|
||||
RateLimit: user.RateLimit,
|
||||
}
|
||||
|
||||
// Cache the application data - log the error but don't fail the operation
|
||||
if err := d.Cache.SetApplicationByAPIKey(ctx, appCache, ApplicationCacheDuration); err != nil {
|
||||
// Log the error but continue since we have the data
|
||||
fmt.Printf("warning: failed to cache application: %v\n", err)
|
||||
}
|
||||
|
||||
return appCache, nil
|
||||
}
|
||||
|
||||
// errorResponse creates a standardized error response
|
||||
func errorResponse(err error, statusCode int32) (*dev.APIValidationResponse, error) {
|
||||
return &dev.APIValidationResponse{
|
||||
Valid: false,
|
||||
ErrorMessage: err.Error(),
|
||||
StatusCode: statusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hasRequiredPermission checks if the permissions list contains the required service and permission
|
||||
func hasRequiredPermission(permissions []model.Permission, service, permissionName string) bool {
|
||||
for _, p := range permissions {
|
||||
if p.PermissionName == permissionName && p.Service == service {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// buildResponse constructs a validation response with common fields
|
||||
func buildResponse(app *model.ApplicationCache, limit *ratelimit.RateLimitStatus, valid bool, errorMsg string, statusCode int32) *dev.APIValidationResponse {
|
||||
return &dev.APIValidationResponse{
|
||||
Valid: valid,
|
||||
ErrorMessage: errorMsg,
|
||||
StatusCode: statusCode,
|
||||
RemainingBurst: limit.RemainingBurst,
|
||||
RemainingDaily: limit.RemainingDaily,
|
||||
RemainingMonthly: limit.RemainingMonthly,
|
||||
BurstLimit: app.RateLimit.BurstLimit,
|
||||
BurstDurationSeconds: app.RateLimit.BurstDuration,
|
||||
DailyLimit: app.RateLimit.DailyLimit,
|
||||
MonthlyLimit: app.RateLimit.MonthlyLimit,
|
||||
UserID: app.UserID,
|
||||
Application: app.Name,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user