feat: add FlareSolverr proxy support with DB-backed config

- Add config table for storing FlareSolverr proxy setting
- Add HTTP endpoints to get/set proxy status (GET/POST /api/config/flaresolverr)
- Refactor httpclient to support proxy mode (requests go through FlareSolverr)
- Add verbose logging for debugging
- Add POST support to FlareSolverr client

Usage:
  GET /api/config/flaresolverr - returns {flaresolverr_proxy: bool}
  POST /api/config/flaresolverr - body: {enabled: true/false}
This commit is contained in:
Achmad
2026-05-11 09:25:48 +00:00
parent 3741f4f696
commit b992080c95
15 changed files with 361 additions and 36 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ root = "."
tmp_dir = "tmp" tmp_dir = "tmp"
[build] [build]
cmd = "go build -o ./tmp/goyomi ./cmd/server" cmd = "go build -buildvcs=false -o ./tmp/goyomi ./cmd/server"
bin = "./tmp/goyomi" bin = "./tmp/goyomi"
include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "json"] include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "json"]
exclude_dir = ["tmp", "vendor", ".git"] exclude_dir = ["tmp", "vendor", ".git"]
+2
View File
@@ -8,7 +8,9 @@
# Go tooling # Go tooling
*.test *.test
*.out *.out
*.log
/vendor/ /vendor/
tmp/
# IDE # IDE
.idea/ .idea/
+3 -2
View File
@@ -1,4 +1,5 @@
DEV_COMPOSE = docker compose -f compose-dev.yml --env-file dev.env DEV_COMPOSE = docker compose -f compose-dev.yml
DEV_GO = /usr/local/go/bin/go
.PHONY: dev-up dev-shell dev-down dev-logs dev-build dev-run smoke-keyoapp-plain smoke-keyoapp-plain-dev .PHONY: dev-up dev-shell dev-down dev-logs dev-build dev-run smoke-keyoapp-plain smoke-keyoapp-plain-dev
@@ -25,4 +26,4 @@ smoke-keyoapp-plain:
smoke-keyoapp-plain-dev: smoke-keyoapp-plain-dev:
$(DEV_COMPOSE) up -d postgres flaresolverr dev $(DEV_COMPOSE) up -d postgres flaresolverr dev
$(DEV_COMPOSE) exec dev sh -lc "GOYOMI_SMOKE=1 go test -count=1 -v ./sources -run TestStandaloneKeyoappSmoke" $(DEV_COMPOSE) exec dev sh -lc "GOYOMI_SMOKE=1 $(DEV_GO) test -count=1 -v ./sources -run TestStandaloneKeyoappSmoke"
+46
View File
@@ -2,12 +2,15 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"goyomi/internal/config" "goyomi/internal/config"
"goyomi/internal/db" "goyomi/internal/db"
"goyomi/internal/httpclient"
_ "goyomi/internal/registry" _ "goyomi/internal/registry"
_ "goyomi/sources/all/hentaihand" _ "goyomi/sources/all/hentaihand"
_ "goyomi/sources/all/kemono" _ "goyomi/sources/all/kemono"
@@ -40,13 +43,56 @@ func main() {
} }
defer database.Close() defer database.Close()
// Initialize config manager
config.InitConfigManager(database.Queries)
if os.Getenv("FLARESOLVERR_URL") != "" {
fsClient, err := httpclient.NewFlareSolverrClient()
if err != nil {
log.Printf("flaresolverr: client creation failed: %v", err)
} else {
httpclient.SetGlobalFlareSolverr(fsClient)
log.Printf("flaresolverr: client initialized")
}
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok") fmt.Fprintln(w, "ok")
}) })
// Config endpoints
mux.HandleFunc("/api/config/flaresolverr", handleFlareSolverrConfig)
log.Printf("listening on %s", cfg.Addr) log.Printf("listening on %s", cfg.Addr)
if err := http.ListenAndServe(cfg.Addr, mux); err != nil { if err := http.ListenAndServe(cfg.Addr, mux); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
func handleFlareSolverrConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
switch r.Method {
case "GET":
enabled := config.IsFlareSolverrProxyEnabled()
json.NewEncoder(w).Encode(map[string]bool{"flaresolverr_proxy": enabled})
case "POST":
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if err := config.SetFlareSolverrProxyEnabled(ctx, req.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]bool{"flaresolverr_proxy": req.Enabled})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
+1
View File
@@ -19,3 +19,4 @@ CHAPTER_LIST_TTL_SECONDS=600
# FlareSolverr # FlareSolverr
FLARESOLVERR_LOG_LEVEL=info FLARESOLVERR_LOG_LEVEL=info
FLARESOLVERR_PROXY=0
+24 -15
View File
@@ -7,26 +7,28 @@ import (
) )
type Config struct { type Config struct {
DatabaseURL string DatabaseURL string
FlareSolverrURL string FlareSolverrURL string
Addr string FlareSolverrProxy bool
MangaListTTL time.Duration Addr string
MangaDetailTTL time.Duration MangaListTTL time.Duration
MangaDetailTTL time.Duration
ChapterListTTL time.Duration ChapterListTTL time.Duration
DBMaxConns int DBMaxConns int
DBMinConns int DBMinConns int
} }
func Load() Config { func Load() Config {
return Config{ return Config{
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
FlareSolverrURL: os.Getenv("FLARESOLVERR_URL"), FlareSolverrURL: os.Getenv("FLARESOLVERR_URL"),
Addr: envStr("ADDR", ":8080"), FlareSolverrProxy: envBool("FLARESOLVERR_PROXY", false),
MangaListTTL: time.Duration(envInt("MANGA_LIST_TTL_SECONDS", 600)) * time.Second, Addr: envStr("ADDR", ":8080"),
MangaDetailTTL: time.Duration(envInt("MANGA_DETAIL_TTL_SECONDS", 3600)) * time.Second, MangaListTTL: time.Duration(envInt("MANGA_LIST_TTL_SECONDS", 600)) * time.Second,
ChapterListTTL: time.Duration(envInt("CHAPTER_LIST_TTL_SECONDS", 600)) * time.Second, MangaDetailTTL: time.Duration(envInt("MANGA_DETAIL_TTL_SECONDS", 3600)) * time.Second,
DBMaxConns: envInt("DB_MAX_CONNS", 10), ChapterListTTL: time.Duration(envInt("CHAPTER_LIST_TTL_SECONDS", 600)) * time.Second,
DBMinConns: envInt("DB_MIN_CONNS", 2), DBMaxConns: envInt("DB_MAX_CONNS", 10),
DBMinConns: envInt("DB_MIN_CONNS", 2),
} }
} }
@@ -45,3 +47,10 @@ func envInt(key string, def int) int {
} }
return def return def
} }
func envBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
return v == "1" || v == "true" || v == "yes"
}
return def
}
+66
View File
@@ -0,0 +1,66 @@
package config
import (
"context"
"sync"
"goyomi/internal/db/queries"
)
type ConfigManager struct {
mu sync.RWMutex
flaresolverrProxy bool
queries *queries.Queries
}
var (
manager *ConfigManager
managerOnce sync.Once
)
func InitConfigManager(q *queries.Queries) {
managerOnce.Do(func() {
manager = &ConfigManager{queries: q}
manager.loadFromDB(context.Background())
})
}
func (m *ConfigManager) loadFromDB(ctx context.Context) {
if m.queries == nil {
return
}
val, err := m.queries.GetConfigValue(ctx, "flaresolverr_proxy")
if err == nil && val != "" {
m.flaresolverrProxy = val == "true"
}
}
func IsFlareSolverrProxyEnabled() bool {
if manager == nil {
return false
}
manager.mu.RLock()
defer manager.mu.RUnlock()
return manager.flaresolverrProxy
}
func SetFlareSolverrProxyEnabled(ctx context.Context, enabled bool) error {
if manager == nil {
return nil
}
value := "false"
if enabled {
value = "true"
}
err := manager.queries.SetConfigValue(ctx, queries.SetConfigValueParams{
Key: "flaresolverr_proxy",
Value: value,
})
if err != nil {
return err
}
manager.mu.Lock()
manager.flaresolverrProxy = enabled
manager.mu.Unlock()
return nil
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS config;
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS config (
key VARCHAR(256) PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO config (key, value) VALUES ('flaresolverr_proxy', 'false') ON CONFLICT DO NOTHING;
+6
View File
@@ -0,0 +1,6 @@
-- name: GetConfigValue :one
SELECT value FROM config WHERE key = $1;
-- name: SetConfigValue :exec
INSERT INTO config (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;
+36
View File
@@ -0,0 +1,36 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: config.sql
package queries
import (
"context"
)
const getConfigValue = `-- name: GetConfigValue :one
SELECT value FROM config WHERE key = $1
`
func (q *Queries) GetConfigValue(ctx context.Context, key string) (string, error) {
row := q.db.QueryRow(ctx, getConfigValue, key)
var value string
err := row.Scan(&value)
return value, err
}
const setConfigValue = `-- name: SetConfigValue :exec
INSERT INTO config (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`
type SetConfigValueParams struct {
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
}
func (q *Queries) SetConfigValue(ctx context.Context, arg SetConfigValueParams) error {
_, err := q.db.Exec(ctx, setConfigValue, arg.Key, arg.Value)
return err
}
+5
View File
@@ -23,6 +23,11 @@ type Chapter struct {
PageCount int32 `db:"page_count" json:"page_count"` PageCount int32 `db:"page_count" json:"page_count"`
} }
type Config struct {
Key string `db:"key" json:"key"`
Value string `db:"value" json:"value"`
}
type Manga struct { type Manga struct {
ID int32 `db:"id" json:"id"` ID int32 `db:"id" json:"id"`
SourceID int64 `db:"source_id" json:"source_id"` SourceID int64 `db:"source_id" json:"source_id"`
+134 -11
View File
@@ -2,23 +2,58 @@ package httpclient
import ( import (
"context" "context"
"fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
"goyomi/internal/config"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
const defaultUserAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36" const defaultUserAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36"
var (
globalFSClient *FlareSolverrClient
globalProxy bool
verboseLog bool
)
func SetGlobalFlareSolverr(client *FlareSolverrClient) {
globalFSClient = client
}
func SetGlobalProxy(enabled bool) {
globalProxy = enabled
}
func SetVerboseLog(enabled bool) {
verboseLog = enabled
}
func ProxyEnabled() bool {
if globalProxy {
return true
}
if config.IsFlareSolverrProxyEnabled() {
return true
}
return false
}
type Client struct { type Client struct {
http *http.Client http *http.Client
rateLimit float64 fsClient *FlareSolverrClient
burst int proxy bool
referer string rateLimit float64
burst int
referer string
verboseLog bool
mu sync.Mutex mu sync.Mutex
limiters map[string]*rate.Limiter limiters map[string]*rate.Limiter
@@ -41,16 +76,28 @@ func WithReferer(referer string) Option {
return func(c *Client) { c.referer = referer } return func(c *Client) { c.referer = referer }
} }
func WithFlareSolverr(fs *FlareSolverrClient) Option {
return func(c *Client) { c.fsClient = fs }
}
func WithProxy(enabled bool) Option {
return func(c *Client) { c.proxy = enabled }
}
func WithVerboseLog(enabled bool) Option {
return func(c *Client) { c.verboseLog = enabled }
}
func NewClient(opts ...Option) *Client { func NewClient(opts ...Option) *Client {
jar, _ := cookiejar.New(nil) jar, _ := cookiejar.New(nil)
c := &Client{ c := &Client{
http: &http.Client{ http: &http.Client{Timeout: 30 * time.Second, Jar: jar},
Timeout: 30 * time.Second, fsClient: globalFSClient,
Jar: jar, proxy: globalProxy,
}, rateLimit: 1,
rateLimit: 1, burst: 1,
burst: 1, limiters: map[string]*rate.Limiter{},
limiters: map[string]*rate.Limiter{}, verboseLog: verboseLog,
} }
for _, o := range opts { for _, o := range opts {
o(c) o(c)
@@ -70,6 +117,13 @@ func (c *Client) limiter(host string) *rate.Limiter {
} }
func (c *Client) Do(req *http.Request) (*http.Response, error) { func (c *Client) Do(req *http.Request) (*http.Response, error) {
if (c.proxy || ProxyEnabled()) && c.fsClient != nil {
return c.doViaFlareSolverr(req)
}
return c.doDirect(req)
}
func (c *Client) doDirect(req *http.Request) (*http.Response, error) {
if err := c.limiter(req.URL.Host).Wait(req.Context()); err != nil { if err := c.limiter(req.URL.Host).Wait(req.Context()); err != nil {
return nil, err return nil, err
} }
@@ -80,12 +134,19 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", defaultUserAgent) req.Header.Set("User-Agent", defaultUserAgent)
} }
if c.verboseLog {
log.Printf("[httpclient] DIRECT GET %s", req.URL.String())
}
const maxRetries = 3 const maxRetries = 3
for attempt := 0; attempt <= maxRetries; attempt++ { for attempt := 0; attempt <= maxRetries; attempt++ {
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if c.verboseLog {
log.Printf("[httpclient] DIRECT RESPONSE %s status=%d", req.URL.String(), resp.StatusCode)
}
if resp.StatusCode != http.StatusTooManyRequests { if resp.StatusCode != http.StatusTooManyRequests {
return resp, nil return resp, nil
} }
@@ -103,6 +164,68 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
panic("unreachable") panic("unreachable")
} }
func (c *Client) doViaFlareSolverr(req *http.Request) (*http.Response, error) {
if err := c.limiter(req.URL.Host).Wait(req.Context()); err != nil {
return nil, err
}
url := req.URL.String()
if c.verboseLog {
log.Printf("[httpclient] FLARESOLVERR %s %s", req.Method, url)
}
var html string
var cookies []*http.Cookie
var err error
if req.Method == http.MethodGet {
html, cookies, err = c.fsClient.Get(req.Context(), url)
} else if req.Method == http.MethodPost {
var bodyStr string
if req.Body != nil {
bodyBytes, _ := io.ReadAll(req.Body)
bodyStr = string(bodyBytes)
if c.verboseLog {
log.Printf("[httpclient] FLARESOLVERR POST BODY: %s", bodyStr)
}
}
html, cookies, err = c.fsClient.Post(req.Context(), url, bodyStr)
} else {
return c.doDirect(req)
}
if err != nil {
return nil, fmt.Errorf("flaresolverr: %w", err)
}
if c.verboseLog {
log.Printf("[httpclient] FLARESOLVERR RESPONSE length=%d cookies=%d", len(html), len(cookies))
if len(html) > 0 {
truncate := html
if len(html) > 500 {
truncate = html[:500]
}
log.Printf("[httpclient] FLARESOLVERR RESPONSE HTML (first 500 chars): %s", truncate)
}
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{},
Body: io.NopCloser(strings.NewReader(html)),
}
resp.Header.Set("Content-Type", "text/html; charset=utf-8")
if c.http.Jar != nil {
for _, cookie := range cookies {
c.http.Jar.SetCookies(req.URL, []*http.Cookie{cookie})
}
}
return resp, nil
}
func retryAfter(resp *http.Response) time.Duration { func retryAfter(resp *http.Response) time.Duration {
ra := resp.Header.Get("Retry-After") ra := resp.Header.Get("Retry-After")
if ra == "" { if ra == "" {
+30 -6
View File
@@ -55,19 +55,43 @@ type fsCookie struct {
// Get fetches a Cloudflare-protected URL via FlareSolverr. // Get fetches a Cloudflare-protected URL via FlareSolverr.
// Returns rendered HTML and extracted cookies. // Returns rendered HTML and extracted cookies.
func (f *FlareSolverrClient) Get(ctx context.Context, url string) (html string, cookies []*http.Cookie, err error) { func (f *FlareSolverrClient) Get(ctx context.Context, url string) (html string, cookies []*http.Cookie, err error) {
payload, _ := json.Marshal(flareSolverrRequest{ return f.request(ctx, "request.get", url, "", nil)
Cmd: "request.get", }
func (f *FlareSolverrClient) Post(ctx context.Context, url string, body string) (html string, cookies []*http.Cookie, err error) {
return f.request(ctx, "request.post", url, body, map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
}
func (f *FlareSolverrClient) request(ctx context.Context, cmd, url, body string, headers map[string]string) (html string, cookies []*http.Cookie, err error) {
type fsRequest struct {
Cmd string `json:"cmd"`
URL string `json:"url"`
PostData string `json:"postData,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
MaxTimeout int `json:"maxTimeout"`
}
req := fsRequest{
Cmd: cmd,
URL: url, URL: url,
MaxTimeout: 60000, MaxTimeout: 60000,
}) }
if body != "" {
req.PostData = body
}
if headers != nil {
req.Headers = headers
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.endpoint+"/v1", bytes.NewReader(payload)) payload, _ := json.Marshal(req)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, f.endpoint+"/v1", bytes.NewReader(payload))
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
req.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Content-Type", "application/json")
resp, err := f.client.Do(req) resp, err := f.client.Do(httpReq)
if err != nil { if err != nil {
return "", nil, err return "", nil, err
} }
-1
View File
@@ -1 +0,0 @@
exit status 1