M api/api_test.go => api/api_test.go +4 -1
@@ 774,6 774,9 @@ func TestAPI(t *testing.T) {
})
t.Run("org add member already existing user", func(t *testing.T) {
+ ctxNoAuth := server.ServerContext(context.Background(), srv)
+ ctxNoAuth = auth.Context(ctxNoAuth, test.NewTestUser(1, false, false, false, false))
+ ctxNoAuth = crypto.Context(ctxNoAuth, entropy)
type GraphQLResponseUser struct {
User models.User `json:"register"`
}
@@ 790,7 793,7 @@ func TestAPI(t *testing.T) {
op.Var("email", "already@existing.com")
op.Var("pass", "qwerty")
op.Var("username", "already_existing")
- err := links.Execute(ctx, op, &resultUser)
+ err := links.Execute(ctxNoAuth, op, &resultUser)
c.NoError(err)
user := resultUser.User
user.SetVerified(true)
M api/graph/generated.go => api/graph/generated.go +82 -6
@@ 9713,8 9713,28 @@ func (ec *executionContext) _Mutation_register(ctx context.Context, field graphq
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
- ctx = rctx // use context from middleware stack in children
- return ec.resolvers.Mutation().Register(rctx, fc.Args["input"].(*model.RegisterInput))
+ directive0 := func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Mutation().Register(rctx, fc.Args["input"].(*model.RegisterInput))
+ }
+ directive1 := func(ctx context.Context) (interface{}, error) {
+ if ec.directives.Internal == nil {
+ return nil, errors.New("directive internal is not implemented")
+ }
+ return ec.directives.Internal(ctx, nil, directive0)
+ }
+
+ tmp, err := directive1(rctx)
+ if err != nil {
+ return nil, graphql.ErrorOnPath(ctx, err)
+ }
+ if tmp == nil {
+ return nil, nil
+ }
+ if data, ok := tmp.(*models.User); ok {
+ return data, nil
+ }
+ return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/models.User`, tmp)
})
if err != nil {
ec.Error(ctx, err)
@@ 11204,8 11224,36 @@ func (ec *executionContext) _Mutation_addQRCode(ctx context.Context, field graph
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
- ctx = rctx // use context from middleware stack in children
- return ec.resolvers.Mutation().AddQRCode(rctx, fc.Args["input"].(model.AddQRCodeInput))
+ directive0 := func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Mutation().AddQRCode(rctx, fc.Args["input"].(model.AddQRCodeInput))
+ }
+ directive1 := func(ctx context.Context) (interface{}, error) {
+ scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "QRCODES")
+ if err != nil {
+ return nil, err
+ }
+ kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RW")
+ if err != nil {
+ return nil, err
+ }
+ if ec.directives.Access == nil {
+ return nil, errors.New("directive access is not implemented")
+ }
+ return ec.directives.Access(ctx, nil, directive0, scope, kind)
+ }
+
+ tmp, err := directive1(rctx)
+ if err != nil {
+ return nil, graphql.ErrorOnPath(ctx, err)
+ }
+ if tmp == nil {
+ return nil, nil
+ }
+ if data, ok := tmp.(*models.QRCode); ok {
+ return data, nil
+ }
+ return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/models.QRCode`, tmp)
})
if err != nil {
ec.Error(ctx, err)
@@ 17589,8 17637,36 @@ func (ec *executionContext) _Query_getQRList(ctx context.Context, field graphql.
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
- ctx = rctx // use context from middleware stack in children
- return ec.resolvers.Query().GetQRList(rctx, fc.Args["orgSlug"].(string), fc.Args["codeType"].(int), fc.Args["elementId"].(int))
+ directive0 := func(rctx context.Context) (interface{}, error) {
+ ctx = rctx // use context from middleware stack in children
+ return ec.resolvers.Query().GetQRList(rctx, fc.Args["orgSlug"].(string), fc.Args["codeType"].(int), fc.Args["elementId"].(int))
+ }
+ directive1 := func(ctx context.Context) (interface{}, error) {
+ scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "QRCODES")
+ if err != nil {
+ return nil, err
+ }
+ kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
+ if err != nil {
+ return nil, err
+ }
+ if ec.directives.Access == nil {
+ return nil, errors.New("directive access is not implemented")
+ }
+ return ec.directives.Access(ctx, nil, directive0, scope, kind)
+ }
+
+ tmp, err := directive1(rctx)
+ if err != nil {
+ return nil, graphql.ErrorOnPath(ctx, err)
+ }
+ if tmp == nil {
+ return nil, nil
+ }
+ if data, ok := tmp.([]*model.QRObject); ok {
+ return data, nil
+ }
+ return nil, fmt.Errorf(`unexpected type %T from directive, should be []*links/api/graph/model.QRObject`, tmp)
})
if err != nil {
ec.Error(ctx, err)
M api/graph/schema.graphqls => api/graph/schema.graphqls +3 -3
@@ 727,7 727,7 @@ type Query {
getListingLink(slug: String!, id: Int!, domainId: Int!): ListingLink! @access(scope: LISTS, kind: RO)
"Returns an array of QR codes"
- getQRList(orgSlug: String!, codeType: Int!, elementId: Int!): [QRObject]!
+ getQRList(orgSlug: String!, codeType: Int!, elementId: Int!): [QRObject]! @access(scope: QRCODES, kind: RO)
"Returns a specific QR code"
getQRDetail(hashId: String!, orgSlug: String): QRCode! @access(scope: QRCODES, kind: RO)
@@ 766,7 766,7 @@ type Mutation {
confirmMember(key: String!): AddMemberPayload! @access(scope: PROFILE, kind: RW)
"Register an account"
- register(input: RegisterInput): User!
+ register(input: RegisterInput): User! @internal
completeRegister(input: CompleteRegisterInput): User! @access(scope: PROFILE, kind: RW)
"Update user profile"
@@ 793,7 793,7 @@ type Mutation {
deleteListingLink(id: Int!): DeletePayload! @access(scope: LISTS, kind: RW)
"Add/Delete QR codes from short links and link listings"
- addQRCode(input: AddQRCodeInput!): QRCode!
+ addQRCode(input: AddQRCodeInput!): QRCode! @access(scope: QRCODES, kind: RW)
deleteQRCode(id: Int!): DeletePayload! @access(scope: QRCODES, kind: RW)
"Un/Follow an organization as the calling user"
M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +2 -2
@@ 2,7 2,7 @@ package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
-// Code generated by github.com/99designs/gqlgen version v0.17.29
+// Code generated by github.com/99designs/gqlgen version v0.17.49
import (
"bytes"
@@ 40,7 40,7 @@ import (
"golang.org/x/image/draw"
"golang.org/x/net/idna"
"netlandish.com/x/gobwebs"
- oauth2 "netlandish.com/x/gobwebs-oauth2"
+ "netlandish.com/x/gobwebs-oauth2"
gaccounts "netlandish.com/x/gobwebs/accounts"
"netlandish.com/x/gobwebs/crypto"
"netlandish.com/x/gobwebs/database"
M cmd/api/main.go => cmd/api/main.go +77 -0
@@ 2,6 2,7 @@ package main
import (
"context"
+ "encoding/hex"
"fmt"
"html/template"
"links"
@@ 12,15 13,20 @@ import (
"links/api/loaders"
"links/cmd"
"links/core"
+ "net"
"net/url"
"os"
"strconv"
+ "strings"
"time"
work "git.sr.ht/~sircmpwn/dowork"
"github.com/99designs/gqlgen/graphql"
"github.com/labstack/echo/v4"
+ "github.com/labstack/echo/v4/middleware"
+ "golang.org/x/time/rate"
gobwebsgql "netlandish.com/x/gobwebs-graphql"
+ oauth2 "netlandish.com/x/gobwebs-oauth2"
feedback "netlandish.com/x/gobwebs-ses-feedback"
"netlandish.com/x/gobwebs/config"
"netlandish.com/x/gobwebs/crypto"
@@ 69,6 75,46 @@ func run() error {
}
}
+ var wlnets []*net.IPNet
+ if val, ok := config.File.Get("links", "rate-limit-whitelist"); ok {
+ for _, nr := range strings.Split(val, ",") {
+ nr = strings.TrimSpace(nr)
+ _, subnet, err := net.ParseCIDR(nr)
+ if err != nil {
+ return fmt.Errorf("links:rate-limit-whitelist %s is invalid", nr)
+ }
+ wlnets = append(wlnets, subnet)
+ }
+ } else {
+ _, subnet, _ := net.ParseCIDR("127.0.0.0/8")
+ wlnets = append(wlnets, subnet)
+ }
+
+ rlnums := struct {
+ Limit int
+ Burst int
+ Expire time.Duration
+ }{20, 40, 3 * time.Minute}
+ if val, ok := config.File.Get("links", "rate-limit-limit"); ok {
+ rlnums.Limit, err = strconv.Atoi(val)
+ if err != nil {
+ return fmt.Errorf("links:rate-limit-limit must be an integer value")
+ }
+ }
+ if val, ok := config.File.Get("links", "rate-limit-burst"); ok {
+ rlnums.Burst, err = strconv.Atoi(val)
+ if err != nil {
+ return fmt.Errorf("links:rate-limit-burst must be an integer value")
+ }
+ }
+ if val, ok := config.File.Get("links", "rate-limit-expire"); ok {
+ expire, err := strconv.Atoi(val)
+ if err != nil {
+ return fmt.Errorf("links:rate-limit-expire must be an integer value")
+ }
+ rlnums.Expire = time.Duration(expire) * time.Minute
+ }
+
esvc, err := cmd.LoadEmailService(config)
if err != nil {
return fmt.Errorf("unable to load email service: %v", err)
@@ 98,6 144,36 @@ func run() error {
Sessions: false,
ServerContext: true,
}
+
+ rlConfig := middleware.RateLimiterConfig{
+ Skipper: func(c echo.Context) bool {
+ ip := net.ParseIP(c.RealIP())
+ for _, subnet := range wlnets {
+ if subnet.Contains(ip) {
+ return true
+ }
+ }
+ return false
+ },
+ Store: middleware.NewRateLimiterMemoryStoreWithConfig(
+ middleware.RateLimiterMemoryStoreConfig{
+ Rate: rate.Limit(rlnums.Limit),
+ Burst: rlnums.Burst,
+ ExpiresIn: rlnums.Expire,
+ },
+ ),
+ IdentifierExtractor: func(c echo.Context) (string, error) {
+ tuser := oauth2.ForContext(c.Request().Context())
+ if tuser != nil {
+ hashStr := hex.EncodeToString(tuser.TokenHash[:])
+ if hashStr != "" {
+ return hashStr, nil
+ }
+ }
+ return c.RealIP(), nil
+ },
+ }
+
srv := server.New(e, db, config).
Initialize().
WithAppInfo("links-api", Version).
@@ 111,6 187,7 @@ func run() error {
core.TimezoneContext(),
crypto.Middleware(entropy),
core.InternalAuthMiddleware(accounts.NewUserFetch()),
+ middleware.RateLimiterWithConfig(rlConfig),
)
// graphql
M config.example.ini => config.example.ini +13 -0
@@ 169,6 169,19 @@ api-listen-port=8003
domains-listen-address=localhost
domains-listen-port=8004
+# White list IP ranges (MUST be in network/mask format) from the system
+# rate limiting functions
+rate-limit-whitelist=127.0.0.0/8
+
+# How many API calls per minute are allowed
+rate-limit-limit=20
+
+# How many are allowed in a burst moment
+rate-limit-burst=40
+
+# How long (in minutes) of inactivity does the limit record live for
+rate-limit-expire=3
+
[stripe]
secret-key=
public-key=
M go.mod => go.mod +1 -1
@@ 26,6 26,7 @@ require (
golang.org/x/image v0.0.0-20211028202545-6944b10bf410
golang.org/x/net v0.26.0
golang.org/x/text v0.16.0
+ golang.org/x/time v0.5.0
netlandish.com/x/gobwebs v0.0.0-20240815220346-26dc25c49eef
netlandish.com/x/gobwebs-formguard v0.0.0-20231224192353-29706d23f156
netlandish.com/x/gobwebs-graphql v0.0.0-20240827211219-adfac6aa2e95
@@ 154,7 155,6 @@ require (
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
- golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/api v0.62.0 // indirect