Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,17 @@ jobs:
with:
repo-checkout: false

deploy:
name: Deploy
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, generated, unit, integration, docker, vulncheck]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6
- name: Deploy to Fly.io
run: flyctl deploy --remote-only -a inwheel-api
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ docs/superpowers/
.claude/
!.claude/settings.json
graphify-out/
supabase/.temp/
90 changes: 49 additions & 41 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ type Server struct {
db *gorm.DB
places *place.Repository
engine *a11y.Engine
regLimiter *middleware.RateLimiter
keyLimiter *middleware.RateLimiter
regLimiter *middleware.RateLimiter
keyLimiter *middleware.RateLimiter
readLimiter *middleware.RateLimiter
}

func main() {
Expand Down Expand Up @@ -95,51 +96,20 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

srv := &Server{
db: gormDB,
places: place.NewRepository(gormDB),
engine: &a11y.Engine{},
regLimiter: middleware.NewRateLimiter(ctx, rate.Every(20*time.Minute), 3),
keyLimiter: middleware.NewRateLimiter(ctx, rate.Every(time.Second), 60),
db: gormDB,
places: place.NewRepository(gormDB),
engine: &a11y.Engine{},
regLimiter: middleware.NewRateLimiter(ctx, rate.Every(20*time.Minute), 3),
keyLimiter: middleware.NewRateLimiter(ctx, rate.Every(time.Second), 60),
readLimiter: middleware.NewRateLimiter(ctx, rate.Limit(20), 20),
}

// v1Mux holds only /v1/* routes so the spec validator only wraps those.
// Unversioned routes (/healthz, /readyz, /openapi.yaml) are registered on
// the outer mux and never go through the validator.
v1Mux := http.NewServeMux()

swagger, err := apiv1.GetSpec()
v1Handler, err := buildV1Handler(srv, getEnv("CORS_ALLOWED_ORIGIN", ""))
if err != nil {
slog.Error("Failed to load OpenAPI spec", "error", err)
slog.Error("Failed to build v1 handler", "error", err)
os.Exit(1)
}

strictHandler := apiv1.NewStrictHandlerWithOptions(srv, []apiv1.StrictMiddlewareFunc{injectRequest()}, apiv1.StrictHTTPServerOptions{
RequestErrorHandlerFunc: srv.validationErrorHandler,
ResponseErrorHandlerFunc: func(w http.ResponseWriter, _ *http.Request, err error) {
slog.Error("handler error", "error", err)
writeJSON(w, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
},
})
apiv1.HandlerWithOptions(strictHandler, apiv1.StdHTTPServerOptions{
BaseURL: "/v1",
BaseRouter: v1Mux,
ErrorHandlerFunc: srv.validationErrorHandler,
Middlewares: []apiv1.MiddlewareFunc{
bodySizeLimiter(1 << 20),
},
})

v1Handler := nethttp_middleware.OapiRequestValidatorWithOptions(swagger, &nethttp_middleware.Options{
SilenceServersWarning: true,
Options: openapi3filter.Options{
AuthenticationFunc: srv.authenticate,
},
ErrorHandlerWithOpts: func(_ context.Context, err error, w http.ResponseWriter, r *http.Request, _ nethttp_middleware.ErrorHandlerOpts) {
srv.validationErrorHandler(w, r, err)
},
})(v1Mux)
v1Handler = middleware.CORS(getEnv("CORS_ALLOWED_ORIGIN", ""))(v1Handler)

mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", srv.handleHealthz)
mux.HandleFunc("GET /readyz", srv.handleReadyz)
Expand Down Expand Up @@ -177,6 +147,44 @@ func main() {
cancel()
}

func buildV1Handler(srv *Server, corsOrigin string) (http.Handler, error) {
v1Mux := http.NewServeMux()

swagger, err := apiv1.GetSpec()
if err != nil {
return nil, err
}

strictHandler := apiv1.NewStrictHandlerWithOptions(srv, []apiv1.StrictMiddlewareFunc{injectRequest()}, apiv1.StrictHTTPServerOptions{
RequestErrorHandlerFunc: srv.validationErrorHandler,
ResponseErrorHandlerFunc: func(w http.ResponseWriter, _ *http.Request, err error) {
slog.Error("handler error", "error", err)
writeJSON(w, map[string]string{"error": "internal server error"}, http.StatusInternalServerError)
},
})
apiv1.HandlerWithOptions(strictHandler, apiv1.StdHTTPServerOptions{
BaseURL: "/v1",
BaseRouter: v1Mux,
ErrorHandlerFunc: srv.validationErrorHandler,
Middlewares: []apiv1.MiddlewareFunc{
bodySizeLimiter(1 << 20),
},
})

v1Handler := nethttp_middleware.OapiRequestValidatorWithOptions(swagger, &nethttp_middleware.Options{
SilenceServersWarning: true,
Options: openapi3filter.Options{
AuthenticationFunc: srv.authenticate,
},
ErrorHandlerWithOpts: func(_ context.Context, err error, w http.ResponseWriter, r *http.Request, _ nethttp_middleware.ErrorHandlerOpts) {
srv.validationErrorHandler(w, r, err)
},
})(middleware.PublicRateLimit(srv.readLimiter)(v1Mux))
v1Handler = middleware.CORS(corsOrigin)(v1Handler)

return v1Handler, nil
}

func bodySizeLimiter(maxBytes int64) apiv1.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
45 changes: 45 additions & 0 deletions cmd/api/rate_limit_wiring_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright (C) 2026 InWheel Contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

package main

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/InWheelOrg/inwheel-api/internal/middleware"
"golang.org/x/time/rate"
)

func TestBuildV1Handler_UnverifiedAPIKeyHeaderDoesNotBypassReadLimit(t *testing.T) {
srv := &Server{
readLimiter: middleware.NewRateLimiter(t.Context(), rate.Every(time.Hour), 1),
}
handler, err := buildV1Handler(srv, "")
if err != nil {
t.Fatalf("buildV1Handler: %v", err)
}

newReq := func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/v1/places?lng=1", nil)
r.RemoteAddr = "1.2.3.4:9999"
r.Header.Set("X-API-Key", "garbage")
return r
}

w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, newReq())
if w1.Code != http.StatusBadRequest {
t.Fatalf("first request status = %d, want %d (invalid proximity params, no DB touch)", w1.Code, http.StatusBadRequest)
}

w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, newReq())
if w2.Code != http.StatusTooManyRequests {
t.Fatalf("second request status = %d, want %d (an unverified X-API-Key header must not bypass the read limiter)", w2.Code, http.StatusTooManyRequests)
}
}
23 changes: 23 additions & 0 deletions fly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# fly.toml app configuration file generated for inwheel-api on 2026-08-01T19:12:01+03:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#

app = 'inwheel-api'
primary_region = 'fra'

[build]
dockerfile = 'cmd/api/Dockerfile'

[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']

[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1
30 changes: 30 additions & 0 deletions internal/middleware/public_rate_limit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2026 InWheel Contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

package middleware

import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
)

func PublicRateLimit(rl *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if APIKeyIDFromCtx(r.Context()) == "" && !rl.Allow(ClientIP(r)) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Retry-After", strconv.Itoa(rl.RetryAfterSeconds()))
w.WriteHeader(http.StatusTooManyRequests)
if err := json.NewEncoder(w).Encode(map[string]string{"error": "rate limit exceeded"}); err != nil {
slog.Error("PublicRateLimit: encode failed", "error", err)
}
return
}
next.ServeHTTP(w, r)
})
}
}
106 changes: 106 additions & 0 deletions internal/middleware/public_rate_limit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (C) 2026 InWheel Contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/

package middleware

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"golang.org/x/time/rate"
)

func TestPublicRateLimit_AllowsUnderLimit(t *testing.T) {
t.Parallel()
called := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })

rl := &RateLimiter{r: 1, b: 1}
handler := PublicRateLimit(rl)(next)
r := httptest.NewRequest(http.MethodGet, "/v1/places", nil)
r.RemoteAddr = "1.2.3.4:9999"
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)

if !called {
t.Fatal("next was not called")
}
}

func TestPublicRateLimit_BlocksOverLimit(t *testing.T) {
t.Parallel()
calls := 0
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })

rl := &RateLimiter{r: rate.Every(time.Hour), b: 1}
handler := PublicRateLimit(rl)(next)

r1 := httptest.NewRequest(http.MethodGet, "/v1/places", nil)
r1.RemoteAddr = "1.2.3.4:9999"
handler.ServeHTTP(httptest.NewRecorder(), r1) // consumes the one token

r2 := httptest.NewRequest(http.MethodGet, "/v1/places", nil)
r2.RemoteAddr = "1.2.3.4:9999"
w := httptest.NewRecorder()
handler.ServeHTTP(w, r2)

if calls != 1 {
t.Fatalf("next was called %d times, want 1 (only the first request)", calls)
}
if w.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want %d", w.Code, http.StatusTooManyRequests)
}
}

func TestPublicRateLimit_SkipsAuthenticatedRequests(t *testing.T) {
t.Parallel()
calls := 0
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })

rl := &RateLimiter{r: rate.Every(time.Hour), b: 1}
handler := PublicRateLimit(rl)(next)

authed := func() *http.Request {
r := httptest.NewRequest(http.MethodPost, "/v1/places", nil)
r.RemoteAddr = "1.2.3.4:9999"
return r.WithContext(WithAPIKeyID(r.Context(), "key-id"))
}

handler.ServeHTTP(httptest.NewRecorder(), authed())
handler.ServeHTTP(httptest.NewRecorder(), authed())

if calls != 2 {
t.Fatalf("next was called %d times, want 2 (authenticated requests bypass the read limiter)", calls)
}
}

func TestPublicRateLimit_DoesNotTrustUnverifiedAPIKeyHeader(t *testing.T) {
t.Parallel()
calls := 0
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ })

rl := &RateLimiter{r: rate.Every(time.Hour), b: 1}
handler := PublicRateLimit(rl)(next)

unauthed := func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/v1/places", nil)
r.RemoteAddr = "1.2.3.4:9999"
r.Header.Set("X-API-Key", "garbage")
return r
}

handler.ServeHTTP(httptest.NewRecorder(), unauthed()) // consumes the one token
w := httptest.NewRecorder()
handler.ServeHTTP(w, unauthed())

if calls != 1 {
t.Fatalf("next was called %d times, want 1 (a bare X-API-Key header must not bypass the limiter)", calls)
}
if w.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want %d", w.Code, http.StatusTooManyRequests)
}
}
Loading