diff --git a/docs/superpowers/plans/2026-05-06-allanime-decomposition.md b/docs/superpowers/plans/2026-05-06-allanime-decomposition.md new file mode 100644 index 0000000..8234dfa --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-allanime-decomposition.md @@ -0,0 +1,167 @@ +# AllAnime Client Decomposition Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Decompose the monolithic `api/playback/allanime_client.go` into focused, maintainable units. + +**Architecture:** +1. Move uTLS networking logic to `pkg/net/utls`. +2. Move crypto and key discovery to `api/playback/allanime_crypto.go`. +3. Move data extraction and stream detection to `api/playback/allanime_extractor.go`. +4. Keep core API and GraphQL logic in `api/playback/allanime_client.go`. + +**Tech Stack:** Go (uTLS, SHA256, AES-CTR) + +--- + +### Task 1: Move uTLS RoundTripper to pkg/net/utls + +**Files:** +- Create: `pkg/net/utls/utls.go` +- Modify: `api/playback/allanime_client.go` + +- [ ] **Step 1: Create pkg/net/utls/utls.go** + +```go +package utls + +import ( + "bufio" + "context" + "net" + "net/http" + "time" + + utls "github.com/refraction-networking/utls" + "golang.org/x/net/http2" +) + +type UtlsRoundTripper struct{} + +func (rt *UtlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + port := req.URL.Port() + if port == "" { + if req.URL.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + + addr := net.JoinHostPort(req.URL.Hostname(), port) + conn, err := net.DialTimeout("tcp", addr, 10*time.Second) + if err != nil { + return nil, err + } + + config := &utls.Config{ServerName: req.URL.Hostname()} + uConn := utls.UClient(conn, config, utls.HelloFirefox_Auto) + + if err := uConn.Handshake(); err != nil { + return nil, err + } + + var httpRT http.RoundTripper + switch uConn.ConnectionState().NegotiatedProtocol { + case http2.NextProtoTLS: + t2 := &http2.Transport{ + DialTLSContext: func(ctx context.Context, network, addr string, cfg *utls.Config) (net.Conn, error) { + return uConn, nil + }, + } + httpRT = t2 + default: + t1 := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return uConn, nil + }, + } + httpRT = t1 + } + + return httpRT.RoundTrip(req) +} +``` + +- [ ] **Step 2: Update allanime_client.go to use the new package** + +Remove `utlsRoundTripper` from `api/playback/allanime_client.go` and update `newAllAnimeClient` to use `&utls.UtlsRoundTripper{}`. + +- [ ] **Step 3: Verify build and commit** + +Run: `go build ./api/playback/...` +Expected: PASS + +```bash +git add pkg/net/utls/utls.go api/playback/allanime_client.go +git commit -m "refactor: move utls roundtripper to pkg/net/utls" +``` + +### Task 2: Extract Crypto and Key Discovery + +**Files:** +- Create: `api/playback/allanime_crypto.go` +- Modify: `api/playback/allanime_client.go` + +- [ ] **Step 1: Move crypto logic to allanime_crypto.go** + +Extract constants (`allAnimeAESKey`, `aniCliRawSourceURL`, etc.) and functions (`decryptTobeparsed`, `tryDecryptCTR`, `getAESKey`, `fetchKeyFromForks`, `extractKey`, `getTestPayload`, `validateKeys`, `getAllKeys`) to `api/playback/allanime_crypto.go`. + +- [ ] **Step 2: Update allanime_client.go** + +Remove the extracted code from `api/playback/allanime_client.go`. + +- [ ] **Step 3: Verify build and commit** + +Run: `go build ./api/playback/...` +Expected: PASS + +```bash +git add api/playback/allanime_crypto.go api/playback/allanime_client.go +git commit -m "refactor: extract allanime crypto and key discovery" +``` + +### Task 3: Extract Data Extraction and Stream Detection + +**Files:** +- Create: `api/playback/allanime_extractor.go` +- Modify: `api/playback/allanime_client.go` + +- [ ] **Step 1: Move extraction logic to allanime_extractor.go** + +Extract functions (`extractSourceURLsFromData`, `buildStreamSource`, `sourceReference` struct, `buildSourceReferences`, `extractEpisodeData`, `decodeSourceURL`, `detectStreamType`, `detectEmbedType`, `getMapKeys`) to `api/playback/allanime_extractor.go`. + +- [ ] **Step 2: Update allanime_client.go** + +Remove the extracted code from `api/playback/allanime_client.go`. Ensure `allAnimeClient` methods still call these (now package-level) functions. + +- [ ] **Step 3: Verify build and commit** + +Run: `go build ./api/playback/...` +Expected: PASS + +```bash +git add api/playback/allanime_extractor.go api/playback/allanime_client.go +git commit -m "refactor: extract allanime data extraction logic" +``` + +### Task 4: Final Cleanup and Verification + +**Files:** +- Modify: `api/playback/allanime_client.go` + +- [ ] **Step 1: Final review of allanime_client.go** + +Ensure only `allAnimeClient` struct, `newAllAnimeClient`, `graphqlRequest`, `graphqlRequestWithHash`, `Search`, `GetEpisodes`, `GetAvailableEpisodes`, and `GetEpisodeMetadata` remain. + +- [ ] **Step 2: Run all tests** + +Run: `go test -v ./api/playback/...` +Expected: PASS + +- [ ] **Step 3: Commit final cleanup** + +```bash +git add api/playback/allanime_client.go +git commit -m "refactor: final cleanup of allanime_client.go" +``` diff --git a/docs/superpowers/plans/2026-05-06-extract-allanime-extraction.md b/docs/superpowers/plans/2026-05-06-extract-allanime-extraction.md new file mode 100644 index 0000000..b46e43f --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-extract-allanime-extraction.md @@ -0,0 +1,300 @@ +# Extract AllAnime Data Extraction Logic Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract data parsing and stream detection logic from `allanime_client.go` into a new file `allanime_extractor.go` to improve maintainability and separate concerns. + +**Architecture:** Move non-network utility functions and helper structs related to parsing AllAnime responses and URL decoding/detection into a dedicated extractor file within the same package. + +**Tech Stack:** Go (Standard Library) + +--- + +### Task 1: Create allanime_extractor.go + +**Files:** +- Create: `api/playback/allanime_extractor.go` + +- [ ] **Step 1: Create the file with moved logic** + +```go +package playback + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/url" + "strings" +) + +type sourceReference struct { + URL string + Name string +} + +func (c *allAnimeClient) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource { + episodeData, ok := data["episode"].(map[string]any) + if !ok { + return nil + } + + sourceURLs, ok := episodeData["sourceUrls"].([]any) + if !ok || len(sourceURLs) == 0 { + return nil + } + + references := buildSourceReferences(sourceURLs) + if len(references) == 0 { + return nil + } + + out := make([]StreamSource, 0, len(references)) + for _, ref := range references { + target := strings.TrimSpace(ref.URL) + if target == "" { + continue + } + + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + sourceType := detectStreamType(target) + if sourceType == "unknown" { + sourceType = detectEmbedType(target) + } + + out = append(out, buildStreamSource(target, sourceType, ref.Name)) + continue + } + + decoded := decodeSourceURL(target) + if decoded == "" { + continue + } + + if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") { + sourceType := detectStreamType(decoded) + if sourceType == "unknown" { + sourceType = detectEmbedType(decoded) + } + + out = append(out, buildStreamSource(decoded, sourceType, ref.Name)) + continue + } + + if !strings.HasPrefix(decoded, "/") { + decoded = "/" + decoded + } + + extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded) + if err != nil { + log.Printf("source extraction failed for %s: %v", decoded, err) + continue + } + + out = append(out, extracted...) + } + + return out +} + +func buildStreamSource(url, sourceType, provider string) StreamSource { + return StreamSource{ + URL: url, + Provider: provider, + Type: sourceType, + Referer: allAnimeReferer, + } +} + +func buildSourceReferences(rawSourceURLs []any) []sourceReference { + priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"} + prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}} + + prioritized := make(map[string]sourceReference) + fallback := make([]sourceReference, 0, len(rawSourceURLs)) + seen := make(map[string]struct{}) + + for _, source := range rawSourceURLs { + item, ok := source.(map[string]any) + if !ok { + continue + } + + sourceURL, _ := item["sourceUrl"].(string) + sourceName, _ := item["sourceName"].(string) + sourceURL = strings.TrimSpace(sourceURL) + sourceName = strings.TrimSpace(sourceName) + if sourceURL == "" { + continue + } + + if _, exists := seen[sourceURL]; exists { + continue + } + seen[sourceURL] = struct{}{} + + ref := sourceReference{URL: sourceURL, Name: sourceName} + normalized := strings.ToLower(sourceName) + if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider { + if _, exists := prioritized[normalized]; !exists { + prioritized[normalized] = ref + } + continue + } + + fallback = append(fallback, ref) + } + + ordered := make([]sourceReference, 0, len(prioritized)+len(fallback)) + for _, provider := range priorityOrder { + if ref, ok := prioritized[provider]; ok { + ordered = append(ordered, ref) + } + } + + ordered = append(ordered, fallback...) + return ordered +} + +func extractEpisodeData(data map[string]any) (map[string]any, error) { + episodeData, ok := data["episode"].(map[string]any) + if ok && episodeData != nil { + return episodeData, nil + } + + toBeParsed, ok := data["tobeparsed"].(string) + if !ok || strings.TrimSpace(toBeParsed) == "" { + return nil, fmt.Errorf("episode not found") + } + + decoded, err := decryptTobeparsed(toBeParsed) + if err != nil { + return nil, fmt.Errorf("decode episode payload: %w", err) + } + + var parsed map[string]any + if err := json.Unmarshal(decoded, &parsed); err != nil { + return nil, fmt.Errorf("parse decoded payload: %w", err) + } + + episodeData, ok = parsed["episode"].(map[string]any) + if !ok || episodeData == nil { + return nil, fmt.Errorf("decoded payload missing episode") + } + + return episodeData, nil +} + +func decodeSourceURL(encoded string) string { + if encoded == "" { + return "" + } + + encoded = strings.TrimPrefix(encoded, "--") + + substitutions := map[string]string{ + "79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E", + "7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J", + "73": "K", "74": "L", "75": "M", "76": "N", "77": "O", + "68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T", + "6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y", + "62": "Z", + "59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e", + "5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j", + "53": "k", "54": "l", "55": "m", "56": "n", "57": "o", + "48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t", + "4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y", + "42": "z", + "08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4", + "0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9", + "15": "-", "16": ".", "67": "_", "46": "~", "02": ":", + "17": "/", "07": "?", "1b": "#", "63": "[", "65": "]", + "78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(", + "11": ")", "12": "*", "13": "+", "14": ",", "03": ";", + "05": "=", "1d": "%", + } + + var result strings.Builder + for idx := 0; idx < len(encoded); { + if idx+2 <= len(encoded) { + pair := encoded[idx : idx+2] + if sub, ok := substitutions[pair]; ok { + result.WriteString(sub) + idx += 2 + continue + } + } + + result.WriteByte(encoded[idx]) + idx++ + } + + decoded := result.String() + if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") { + decoded = strings.Replace(decoded, "/clock", "/clock.json", 1) + } + + return decoded +} + +func detectStreamType(sourceURL string) string { + lower := strings.ToLower(sourceURL) + if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") { + return "m3u8" + } + + if strings.Contains(lower, ".mp4") { + return "mp4" + } + + return "unknown" +} + +func detectEmbedType(rawURL string) string { + lower := strings.ToLower(rawURL) + embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"} + for _, host := range embedHosts { + if strings.Contains(lower, host) { + return "embed" + } + } + + return "unknown" +} + +func getMapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add api/playback/allanime_extractor.go +git commit -m "feat: add allanime_extractor.go with moved logic" +``` + +### Task 2: Update allanime_client.go + +**Files:** +- Modify: `api/playback/allanime_client.go` + +- [ ] **Step 1: Remove moved logic from allanime_client.go** +Remove: `extractSourceURLsFromData`, `buildStreamSource`, `sourceReference` struct, `buildSourceReferences`, `extractEpisodeData`, `decodeSourceURL`, `detectStreamType`, `detectEmbedType`, `getMapKeys`. + +- [ ] **Step 2: Verify build** + +Run: `go build ./api/playback/...` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add api/playback/allanime_client.go +git commit -m "refactor: remove moved logic from allanime_client.go" +``` diff --git a/docs/superpowers/plans/2026-05-06-project-structure-cleanup.md b/docs/superpowers/plans/2026-05-06-project-structure-cleanup.md new file mode 100644 index 0000000..c267c71 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-project-structure-cleanup.md @@ -0,0 +1,150 @@ +# Project Structure Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Standardize the Go project structure, consolidate database packages, and remove redundant files to improve maintainability and idiomatic code patterns. + +**Architecture:** +1. Consolidate all database-related code into `internal/db` with the package name `db`. +2. Remove the `internal/db/sqlite` sub-package and move its contents to `internal/db`. +3. Update `sqlc.yaml` and regenerate code. +4. Clean up the `tmp/scripts/` directory. +5. Update all imports throughout the project. + +**Tech Stack:** Go, sqlc, Bun + +--- + +### Task 1: Clean up tmp/scripts + +**Files:** +- Delete: `tmp/scripts/*.go` + +- [ ] **Step 1: Delete redundant scripts** + +Run: `rm tmp/scripts/*.go` + +- [ ] **Step 2: Commit cleanup** + +```bash +git add tmp/scripts/ +git commit -m "chore: remove redundant scripts" +``` + +### Task 2: Standardize internal/db Package and Flatten Structure + +**Files:** +- Modify: `sqlc.yaml` +- Move: `internal/db/sqlite/sqlite.go` -> `internal/db/sqlite.go` +- Modify: `internal/db/*.go` (generated and manual) + +- [ ] **Step 1: Update sqlc configuration** + +Modify `sqlc.yaml`: +```yaml +version: "2" +sql: + - engine: "sqlite" + queries: "internal/db/queries.sql" + schema: "migrations/" + gen: + go: + package: "db" + out: "internal/db" + emit_json_tags: true + emit_prepared_queries: false + emit_interface: true + emit_exact_table_names: false +``` + +- [ ] **Step 2: Regenerate sqlc code** + +Run: `sqlc generate` + +- [ ] **Step 3: Move and update sqlite.go** + +Run: `mv internal/db/sqlite/sqlite.go internal/db/sqlite.go && rm -rf internal/db/sqlite` + +Modify `internal/db/sqlite.go`: +```go +package db + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" +) +// ... existing functions, update return types to use local db package if needed +``` + +- [ ] **Step 4: Update package names in manual files** + +Modify `internal/db/helpers.go` and `internal/db/migrate.go` to use `package db`. + +- [ ] **Step 5: Commit database structural changes** + +```bash +git add internal/db/ sqlc.yaml +git commit -m "refactor: consolidate db package and flatten structure" +``` + +### Task 3: Update Global Imports + +**Files:** +- Modify: All files importing `mal/internal/db` or `mal/internal/db/sqlite` + +- [ ] **Step 1: Replace imports and qualifiers** + +Use `sed` or `edit` to update all imports: +- `database "mal/internal/db"` -> `"mal/internal/db"` +- `"mal/internal/db"` -> `"mal/internal/db"` (ensure it's used as `db.`) +- `"mal/internal/db/sqlite"` -> `"mal/internal/db"` + +- [ ] **Step 2: Update code references** + +Replace `database.` and `sqlite.` with `db.` globally where applicable. + +- [ ] **Step 3: Verify build** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 4: Commit import updates** + +```bash +git add . +git commit -m "refactor: update imports to use new db package" +``` + +### Task 4: Clean dist/ and Verify Build + +**Files:** +- Modify: `package.json` (if needed) +- Clean: `dist/` + +- [ ] **Step 1: Clean dist directory** + +Run: `rm -rf dist/*` + +- [ ] **Step 2: Run frontend build** + +Run: `bun run build:assets` + +- [ ] **Step 3: Verify dist structure** + +Check if `dist/static/static` still exists. If so, investigate `bun build` command in `package.json`. + +- [ ] **Step 4: Final verification** + +Run: `go build ./... && bun run typecheck` +Expected: PASS + +- [ ] **Step 5: Commit build fixes** + +```bash +git add . +git commit -m "build: clean dist and verify assets" +``` diff --git a/docs/superpowers/plans/2026-05-06-rate-limiter-refactor.md b/docs/superpowers/plans/2026-05-06-rate-limiter-refactor.md new file mode 100644 index 0000000..1aa2cec --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-rate-limiter-refactor.md @@ -0,0 +1,277 @@ +# Rate Limiter Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the global-state rate limiter into a struct-based implementation that supports multiple instances with different configurations. + +**Architecture:** +1. Introduce a `Limiter` struct that encapsulates state and configuration. +2. Remove global variables and `init()` in `pkg/middleware/ratelimit.go`. +3. Provide generic and auth-specific middleware methods. +4. Integrate the new `Limiter` into the server startup and router. + +**Tech Stack:** Go (Standard Library) + +--- + +### Task 1: Refactor Limiter Logic and State + +**Files:** +- Modify: `pkg/middleware/ratelimit.go` + +- [ ] **Step 1: Define new structs and remove global state** + +Replace content of `pkg/middleware/ratelimit.go`: +```go +package middleware + +import ( + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +type visitor struct { + attempts int + lastSeen time.Time +} + +type Config struct { + MaxAttempts int + Window time.Duration +} + +type Limiter struct { + mu sync.Mutex + visitors map[string]*visitor + config Config +} + +func NewLimiter(cfg Config) *Limiter { + return &Limiter{ + visitors: make(map[string]*visitor), + config: cfg, + } +} + +func (l *Limiter) Cleanup(now time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + for ip, v := range l.visitors { + if now.Sub(v.lastSeen) > l.config.Window*3 { + delete(l.visitors, ip) + } + } +} + +func getIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + return strings.TrimSpace(ips[0]) + } + if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + return realIP + } + ip := r.RemoteAddr + if colonIdx := strings.LastIndex(ip, ":"); colonIdx != -1 { + ip = ip[:colonIdx] + } + return ip +} +``` + +- [ ] **Step 2: Implement Middleware methods** + +Add to `pkg/middleware/ratelimit.go`: +```go +func (l *Limiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !l.allow(getIP(r)) { + http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +func (l *Limiter) AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !l.allow(getIP(r)) { + if strings.HasPrefix(r.URL.Path, "/") { + http.Redirect(w, r, fmt.Sprintf("%s?error=rate_limited", r.URL.Path), http.StatusFound) + return + } + http.Error(w, "Too many requests. Please try again later.", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +func (l *Limiter) allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + + v, exists := l.visitors[ip] + if !exists { + l.visitors[ip] = &visitor{1, time.Now()} + return true + } + + if time.Since(v.lastSeen) > l.config.Window { + v.attempts = 1 + v.lastSeen = time.Now() + return true + } + + v.attempts++ + v.lastSeen = time.Now() + return v.attempts <= l.config.MaxAttempts +} +``` + +- [ ] **Step 3: Commit refactor** + +```bash +git add pkg/middleware/ratelimit.go +git commit -m "refactor: convert rate limiter to struct-based implementation" +``` + +### Task 2: Add Unit Tests for Limiter + +**Files:** +- Create: `pkg/middleware/ratelimit_test.go` + +- [ ] **Step 1: Write tests for Limiter logic** + +Create `pkg/middleware/ratelimit_test.go`: +```go +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func testHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} + +func TestLimiter(t *testing.T) { + cfg := Config{MaxAttempts: 2, Window: 100 * time.Millisecond} + l := NewLimiter(cfg) + handler := l.Middleware(testHandler()) + + // First attempt + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "1.2.3.4:1234" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + + // Second attempt + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } + + // Third attempt (should fail) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusTooManyRequests { + t.Errorf("expected 429, got %d", rr.Code) + } + + // Wait for window to expire + time.Sleep(150 * time.Millisecond) + + // Fourth attempt (should pass again) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } +} +``` + +- [ ] **Step 2: Run tests** + +Run: `go test -v pkg/middleware/ratelimit.go pkg/middleware/ratelimit_test.go` +Expected: PASS + +- [ ] **Step 3: Commit tests** + +```bash +git add pkg/middleware/ratelimit_test.go +git commit -m "test: add unit tests for rate limiter" +``` + +### Task 3: Integrate Limiter into Server + +**Files:** +- Modify: `internal/server/routes.go` +- Modify: `cmd/server/main.go` + +- [ ] **Step 1: Update Config and NewRouter** + +Modify `internal/server/routes.go`: +```go +type Config struct { + DB *db.Queries + SQLDB *sql.DB + JikanClient *jikan.Client + AuthService *auth.Service + PlaybackProxySecret string + AuthLimiter *pkgmiddleware.Limiter // Add this +} + +// ... in NewRouter function, replace: +// pkgmiddleware.RateLimitAuth(...) +// with: +// cfg.AuthLimiter.AuthMiddleware(...) +``` + +- [ ] **Step 2: Instantiate Limiter in main.go** + +Modify `cmd/server/main.go`: +```go +// ... in main function +authLimiter := pkgmiddleware.NewLimiter(pkgmiddleware.Config{ + MaxAttempts: 5, + Window: time.Minute, +}) + +// Start cleanup goroutine +go func() { + for { + time.Sleep(time.Minute) + authLimiter.Cleanup(time.Now()) + } +}() + +router := server.NewRouter(server.Config{ + // ... + AuthLimiter: authLimiter, +}) +``` + +- [ ] **Step 3: Verify build and manual test** + +Run: `go build ./...` +Expected: PASS + +- [ ] **Step 4: Commit integration** + +```bash +git add internal/server/routes.go cmd/server/main.go +git commit -m "feat: integrate new rate limiter into server" +```