Display rate limit
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-06-22 22:55:17 -04:00
parent 762eaef177
commit 8a448ea9dc
13 changed files with 353 additions and 33 deletions

View File

@@ -5,9 +5,16 @@ import (
)
type UserInfo struct {
ID uint64 `json:"id"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
RateLimit model.RateLimit `json:"rate_limit"`
Permissions []model.Permission `json:"permissions"`
ID uint64 `json:"id"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
RateLimit model.RateLimit `json:"rate_limit"`
RateLimitStatus UserRateLimitStatus `json:"rate_limit_status"`
Permissions []model.Permission `json:"permissions"`
}
type UserRateLimitStatus struct {
RemainingBurst uint64 `json:"remaining_burst"`
RemainingDaily uint64 `json:"remaining_daily"`
RemainingMonthly uint64 `json:"remaining_monthly"`
}

View File

@@ -5,6 +5,7 @@ import (
"git.itzana.me/StrafesNET/dev-service/pkg/authz"
"git.itzana.me/StrafesNET/dev-service/pkg/datastore"
"git.itzana.me/StrafesNET/dev-service/pkg/model"
"git.itzana.me/StrafesNET/dev-service/pkg/ratelimit"
"github.com/gin-gonic/gin"
"net/http"
)
@@ -15,12 +16,14 @@ const (
ErrMsgDatastore = "datastore is required"
ErrMsgAuth = "auth service is required"
ErrMsgUnauth = "Unauthorized"
ErrMsgLimiter = "rate limiter is required"
)
// Handler is a base handler that provides common functionality for all HTTP handlers.
type Handler struct {
Store datastore.Datastore
Auth *authz.Service
Store datastore.Datastore
Auth *authz.Service
Limiter *ratelimit.RateLimit
}
// HandlerOption defines a functional option for configuring a Handler
@@ -40,6 +43,13 @@ func WithAuthService(auth *authz.Service) HandlerOption {
}
}
// WithRatelimiter sets the rate limiter for the Handler
func WithRatelimiter(limiter *ratelimit.RateLimit) HandlerOption {
return func(h *Handler) {
h.Limiter = limiter
}
}
// NewHandler creates a new Handler with the provided options.
// It requires both a datastore and an authentication service to function properly.
func NewHandler(options ...HandlerOption) (*Handler, error) {
@@ -66,6 +76,9 @@ func (h *Handler) validateDependencies() error {
if h.Auth == nil {
return fmt.Errorf(ErrMsgAuth)
}
if h.Limiter == nil {
return fmt.Errorf(ErrMsgLimiter)
}
return nil
}

View File

@@ -3,6 +3,7 @@ package handlers
import (
"git.itzana.me/StrafesNET/dev-service/pkg/api/dto"
"git.itzana.me/StrafesNET/dev-service/pkg/datastore"
"git.itzana.me/StrafesNET/dev-service/pkg/ratelimit"
"github.com/gin-gonic/gin"
"net/http"
)
@@ -56,11 +57,28 @@ func (h *UserHandler) GetCurrentUser(ctx *gin.Context) {
return
}
// Get rate limit
limit, err := h.Limiter.GetRateLimitStatus(ctx, user.ID, ratelimit.RateLimitConfig{
BurstLimit: user.RateLimit.BurstLimit,
BurstDurationSeconds: user.RateLimit.BurstDuration,
DailyLimit: user.RateLimit.DailyLimit,
MonthlyLimit: user.RateLimit.MonthlyLimit,
})
if err != nil {
h.RespondWithError(ctx, http.StatusInternalServerError, err.Error())
return
}
h.RespondWithData(ctx, &dto.UserInfo{
ID: user.ID,
Username: user.Username,
AvatarURL: profile.AvatarURL,
RateLimit: user.RateLimit,
ID: user.ID,
Username: user.Username,
AvatarURL: profile.AvatarURL,
RateLimit: user.RateLimit,
RateLimitStatus: dto.UserRateLimitStatus{
RemainingBurst: limit.RemainingBurst,
RemainingDaily: limit.RemainingDaily,
RemainingMonthly: limit.RemainingMonthly,
},
Permissions: user.Permissions,
}, nil)
}

View File

@@ -8,6 +8,7 @@ import (
"git.itzana.me/StrafesNET/dev-service/pkg/api/middleware"
"git.itzana.me/StrafesNET/dev-service/pkg/authz"
"git.itzana.me/StrafesNET/dev-service/pkg/datastore"
"git.itzana.me/StrafesNET/dev-service/pkg/ratelimit"
"git.itzana.me/StrafesNET/dev-service/web"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@@ -25,6 +26,7 @@ type RouterConfig struct {
context *cli.Context
store datastore.Datastore
auth *authz.Service
limiter *ratelimit.RateLimit
shutdownTimeout time.Duration
}
@@ -56,6 +58,13 @@ func WithShutdownTimeout(timeout time.Duration) Option {
}
}
// WithRateLimit sets the rate limiter for the router
func WithRateLimit(limiter *ratelimit.RateLimit) Option {
return func(cfg *RouterConfig) {
cfg.limiter = limiter
}
}
// WithAuth sets the auth for the router
func WithAuth(auth *authz.Service) Option {
return func(cfg *RouterConfig) {
@@ -72,6 +81,7 @@ func setupRoutes(cfg *RouterConfig) (*gin.Engine, error) {
handlerOptions := []handlers.HandlerOption{
handlers.WithDatastore(cfg.store),
handlers.WithAuthService(cfg.auth),
handlers.WithRatelimiter(cfg.limiter),
}
userHandler, err := handlers.NewUserHandler(handlerOptions...)
@@ -151,6 +161,10 @@ func NewRouter(options ...Option) error {
return errors.New("auth service is required")
}
if cfg.limiter == nil {
return errors.New("rate limiter is required")
}
routes, err := setupRoutes(cfg)
if err != nil {
return err

View File

@@ -66,6 +66,25 @@ var (
Value: 8080,
EnvVars: []string{"PORT"},
},
&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,
},
}
)

View File

@@ -18,27 +18,7 @@ func NewRpcCommand() *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,
},
},
Flags: []cli.Flag{},
}
}

View File

@@ -4,7 +4,9 @@ import (
"git.itzana.me/StrafesNET/dev-service/pkg/api"
"git.itzana.me/StrafesNET/dev-service/pkg/authz"
"git.itzana.me/StrafesNET/dev-service/pkg/datastore/gormstore"
"git.itzana.me/StrafesNET/dev-service/pkg/ratelimit"
"git.itzana.me/strafesnet/go-grpc/auth"
"github.com/redis/go-redis/v9"
"github.com/urfave/cli/v2"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
@@ -39,6 +41,13 @@ func runWeb(ctx *cli.Context) error {
return err
}
// Redis setup
rdb := redis.NewClient(&redis.Options{
Addr: ctx.String("redis-addr"),
Password: ctx.String("redis-pass"),
DB: ctx.Int("redis-db"),
})
// Auth service client
conn, err := grpc.Dial(ctx.String("auth-rpc-host"), grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
@@ -60,5 +69,6 @@ func runWeb(ctx *cli.Context) error {
api.WithPort(ctx.Int("port")),
api.WithDatastore(store),
api.WithAuth(authClient),
api.WithRateLimit(ratelimit.New(rdb)),
)
}

View File

@@ -88,5 +88,39 @@ return {
remainingDaily,
remainingMonthly
}
`
// readOnlyRateLimitScript is a Lua script that checks rate limits without incrementing counters
readOnlyRateLimitScript = `
-- Keys: burstKey, dailyKey, monthlyKey
-- Args: now, cutoff, burstLimit, dailyLimit, monthlyLimit
local now = tonumber(ARGV[1])
local cutoff = tonumber(ARGV[2])
local burstLimit = tonumber(ARGV[3])
local dailyLimit = tonumber(ARGV[4])
local monthlyLimit = tonumber(ARGV[5])
-- Check burst limit (leaky bucket)
local bursts = redis.call('ZRANGEBYSCORE', KEYS[1], cutoff, '+inf')
local burstCount = #bursts
local remainingBurst = burstLimit - burstCount
-- Check daily limit
local dailyCount = redis.call('GET', KEYS[2])
dailyCount = dailyCount and tonumber(dailyCount) or 0
local remainingDaily = dailyLimit - dailyCount
-- Check monthly limit
local monthlyCount = redis.call('GET', KEYS[3])
monthlyCount = monthlyCount and tonumber(monthlyCount) or 0
local remainingMonthly = monthlyLimit - monthlyCount
-- Check if all limits are satisfied
local allowed = 1
if remainingBurst <= 0 or remainingDaily <= 0 or remainingMonthly <= 0 then
allowed = 0
end
return {allowed, remainingBurst, remainingDaily, remainingMonthly}
`
)

View File

@@ -118,6 +118,52 @@ func (rl *RateLimit) CheckRateLimits(ctx context.Context, user uint64, config Ra
}, nil
}
// GetRateLimitStatus retrieves the current usage status of daily and monthly rate limits
// without consuming any of the limits.
func (rl *RateLimit) GetRateLimitStatus(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()
// Execute a read-only Lua script that doesn't modify any counters
result, err := rl.redis.Eval(ctx, readOnlyRateLimitScript,
[]string{burstKey, dailyKey, monthlyKey},
now, // ARGV[1] - Current timestamp
cutoff, // ARGV[2] - Cutoff timestamp
config.BurstLimit, // ARGV[3] - Burst limit
config.DailyLimit, // ARGV[4] - Daily limit
config.MonthlyLimit, // ARGV[5] - Monthly limit
).Result()
if err != nil {
return nil, fmt.Errorf("failed to execute read-only 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 read-only 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)

View File

@@ -18,6 +18,7 @@ import {
} from '@mui/icons-material';
import {UserInfo} from "../types";
import {useConfig} from "../context/ConfigContext.tsx";
import RateLimitDisplay from './RateLimitDisplay';
interface HeaderProps {
user: UserInfo;
@@ -59,6 +60,10 @@ const Header: React.FC<HeaderProps> = ({ user, onCreateAppClick }) => {
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
StrafesNET Developer Portal
</Typography>
{/* Rate Limit Display */}
<RateLimitDisplay rateLimit={user.rate_limit} rateLimitStatus={user.rate_limit_status} />
<Button
color="inherit"
startIcon={<AddIcon />}

View File

@@ -0,0 +1,167 @@
import React from 'react';
import {
Typography,
Tooltip,
Grid,
Box,
LinearProgress,
Divider
} from '@mui/material';
import {
Speed as SpeedIcon,
Today as TodayIcon,
DateRange as DateRangeIcon,
Apps as AppsIcon
} from '@mui/icons-material';
import {RateLimit, RateLimitStatus} from '../types';
interface RateLimitDisplayProps {
rateLimit: RateLimit;
rateLimitStatus: RateLimitStatus
}
const RateLimitDisplay: React.FC<RateLimitDisplayProps> = ({ rateLimit, rateLimitStatus }) => {
const dailyUsed = rateLimit.daily_limit - rateLimitStatus.remaining_daily;
const monthlyUsed = rateLimit.monthly_limit - rateLimitStatus.remaining_monthly;
const dailyPercentage = (dailyUsed / rateLimit.daily_limit) * 100;
const monthlyPercentage = (monthlyUsed / rateLimit.monthly_limit) * 100;
// Create tooltip content separately without a Paper component
const tooltipContent = (
<Box sx={{ p: 1.5, width: 300 }}>
<Typography variant="subtitle1" fontWeight="bold" gutterBottom>
API Rate Limits
</Typography>
<Divider sx={{ my: 1 }} />
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<SpeedIcon fontSize="small" sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body2" fontWeight="medium">
Burst Rate
</Typography>
</Box>
<Typography variant="body2" sx={{ ml: 4 }}>
{rateLimit.burst_limit} requests / {rateLimit.burst_duration} seconds
</Typography>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<TodayIcon fontSize="small" sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body2" fontWeight="medium">
Daily Usage
</Typography>
</Box>
<Box sx={{ ml: 4 }}>
<Typography variant="body2" sx={{ mb: 0.5 }}>
{dailyUsed} / {rateLimit.daily_limit} requests
</Typography>
<LinearProgress
variant="determinate"
value={dailyPercentage}
sx={{
height: 5,
borderRadius: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'& .MuiLinearProgress-bar': {
backgroundColor: dailyPercentage > 75 ? 'error.main' : 'primary.main'
}
}}
/>
</Box>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<DateRangeIcon fontSize="small" sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body2" fontWeight="medium">
Monthly Usage
</Typography>
</Box>
<Box sx={{ ml: 4 }}>
<Typography variant="body2" sx={{ mb: 0.5 }}>
{monthlyUsed} / {rateLimit.monthly_limit} requests
</Typography>
<LinearProgress
variant="determinate"
value={monthlyPercentage}
sx={{
height: 5,
borderRadius: 1,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
'& .MuiLinearProgress-bar': {
backgroundColor: monthlyPercentage > 75 ? 'error.main' : 'primary.main'
}
}}
/>
</Box>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}>
<AppsIcon fontSize="small" sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="body2" fontWeight="medium">
Max Applications
</Typography>
</Box>
<Typography variant="body2" sx={{ ml: 4 }}>
{rateLimit.max_applications}
</Typography>
</Grid>
</Grid>
</Box>
);
return (
<Tooltip
title={tooltipContent}
arrow
placement="bottom-end"
componentsProps={{
tooltip: {
sx: {
bgcolor: 'background.paper',
color: 'text.primary',
'& .MuiTooltip-arrow': {
color: 'background.paper',
},
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.2)',
borderRadius: 1,
p: 0, // Remove padding from tooltip to prevent double padding
}
}
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
px: 1.5,
py: 0.75,
mr: 2,
borderRadius: 4,
backgroundColor: 'rgba(149, 128, 255, 0.15)',
border: '1px solid rgba(149, 128, 255, 0.3)',
color: 'primary.main',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(149, 128, 255, 0.25)',
},
transition: 'background-color 0.2s',
height: 32,
}}
>
<SpeedIcon fontSize="small" sx={{ mr: 1 }} />
<Typography variant="body2" fontWeight="medium">
{dailyUsed}/{rateLimit.daily_limit} API calls today
</Typography>
</Box>
</Tooltip>
);
};
export default RateLimitDisplay;

View File

@@ -9,6 +9,12 @@ export interface RateLimit {
updated_at: string;
}
export interface RateLimitStatus {
remaining_burst: number;
remaining_daily: number;
remaining_monthly: number;
}
export interface Permission {
id: number;
service: string;
@@ -48,6 +54,7 @@ export interface UserInfo {
username: string;
avatar_url: string;
rate_limit: RateLimit;
rate_limit_status: RateLimitStatus;
permissions: Permission[];
}

View File

@@ -7,7 +7,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
target: 'http://localhost:1234',
changeOrigin: true
},
}