docs: add implementation plans for project cleanup
This commit is contained in:
167
docs/superpowers/plans/2026-05-06-allanime-decomposition.md
Normal file
167
docs/superpowers/plans/2026-05-06-allanime-decomposition.md
Normal file
@@ -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"
|
||||
```
|
||||
300
docs/superpowers/plans/2026-05-06-extract-allanime-extraction.md
Normal file
300
docs/superpowers/plans/2026-05-06-extract-allanime-extraction.md
Normal file
@@ -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"
|
||||
```
|
||||
150
docs/superpowers/plans/2026-05-06-project-structure-cleanup.md
Normal file
150
docs/superpowers/plans/2026-05-06-project-structure-cleanup.md
Normal file
@@ -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"
|
||||
```
|
||||
277
docs/superpowers/plans/2026-05-06-rate-limiter-refactor.md
Normal file
277
docs/superpowers/plans/2026-05-06-rate-limiter-refactor.md
Normal file
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user