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