~netlandish/links

efac94298c546b718536ebec35a64dd2eadb0604 — Peter Sanchez a month ago 302ec65
Adding rate limiting support to api server
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