| package main |
|
|
| import ( |
| "bytes" |
| "context" |
| _ "embed" |
| "encoding/json" |
| "io" |
| "log" |
| "net/http" |
| "net/url" |
| "os" |
| "strconv" |
| "strings" |
| "time" |
|
|
| "github.com/gin-gonic/gin" |
| ) |
|
|
| |
| var indexHTML string |
|
|
| const ( |
| defaultListenAddr = ":8890" |
| defaultBaseURL = "https://hdhive.com" |
| defaultTMDBBaseURL = "https://api.tmdb.org" |
| defaultTimeout = 20 * time.Second |
| openAPIPath = "/api/open" |
| ) |
|
|
| type config struct { |
| ListenAddr string |
| BaseURL string |
| DefaultAPIKey string |
| TMDBBaseURL string |
| DefaultTMDBKey string |
| Timeout time.Duration |
| } |
|
|
| type upstreamClient struct { |
| baseURL string |
| defaultAPIKey string |
| tmdbBaseURL string |
| defaultTMDBKey string |
| httpClient *http.Client |
| } |
|
|
| type unlockRequest struct { |
| Slug string `json:"slug"` |
| AllowPoints *bool `json:"allow_points,omitempty"` |
| } |
|
|
| type shareDetailResponse struct { |
| Success bool `json:"success"` |
| Code string `json:"code"` |
| Message string `json:"message"` |
| Data shareDetailData `json:"data"` |
| } |
|
|
| type shareDetailData struct { |
| Slug string `json:"slug"` |
| ActualUnlockPoints int `json:"actual_unlock_points"` |
| IsUnlocked bool `json:"is_unlocked"` |
| IsFreeForUser bool `json:"is_free_for_user"` |
| UnlockMessage string `json:"unlock_message"` |
| } |
|
|
| type localError struct { |
| Success bool `json:"success"` |
| Code string `json:"code"` |
| Message string `json:"message"` |
| Description string `json:"description,omitempty"` |
| Data interface{} `json:"data,omitempty"` |
| } |
|
|
| func main() { |
| cfg := loadConfig() |
| client := newUpstreamClient(cfg) |
|
|
| router := gin.Default() |
| registerRoutes(router, client) |
|
|
| log.Printf("HDHive test client listening on %s (upstream: %s%s)", cfg.ListenAddr, cfg.BaseURL, openAPIPath) |
| if err := router.Run(cfg.ListenAddr); err != nil { |
| log.Fatalf("failed to start HDHive test client: %v", err) |
| } |
| } |
|
|
| func loadConfig() config { |
| timeout := defaultTimeout |
| if raw := strings.TrimSpace(os.Getenv("HDHIVE_TIMEOUT_SECONDS")); raw != "" { |
| if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 { |
| timeout = time.Duration(seconds) * time.Second |
| } |
| } |
|
|
| baseURL := strings.TrimSpace(os.Getenv("HDHIVE_BASE_URL")) |
| if baseURL == "" { |
| baseURL = defaultBaseURL |
| } |
| baseURL = strings.TrimSuffix(baseURL, "/") |
| baseURL = strings.TrimSuffix(baseURL, openAPIPath) |
|
|
| tmdbBaseURL := strings.TrimSpace(os.Getenv("TMDB_BASE_URL")) |
| if tmdbBaseURL == "" { |
| tmdbBaseURL = defaultTMDBBaseURL |
| } |
| tmdbBaseURL = normalizeTMDBBaseURL(tmdbBaseURL) |
|
|
| listenAddr := strings.TrimSpace(os.Getenv("HDHIVE_LISTEN_ADDR")) |
| if listenAddr == "" { |
| listenAddr = defaultListenAddr |
| } |
|
|
| return config{ |
| ListenAddr: listenAddr, |
| BaseURL: baseURL, |
| DefaultAPIKey: strings.TrimSpace(os.Getenv("HDHIVE_API_KEY")), |
| TMDBBaseURL: tmdbBaseURL, |
| DefaultTMDBKey: strings.TrimSpace(os.Getenv("TMDB_API_KEY")), |
| Timeout: timeout, |
| } |
| } |
|
|
| func newUpstreamClient(cfg config) *upstreamClient { |
| return &upstreamClient{ |
| baseURL: cfg.BaseURL, |
| defaultAPIKey: cfg.DefaultAPIKey, |
| tmdbBaseURL: cfg.TMDBBaseURL, |
| defaultTMDBKey: cfg.DefaultTMDBKey, |
| httpClient: &http.Client{ |
| Timeout: cfg.Timeout, |
| }, |
| } |
| } |
|
|
| func registerRoutes(router *gin.Engine, client *upstreamClient) { |
| router.Use(corsMiddleware()) |
|
|
| router.GET("/", func(c *gin.Context) { |
| c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(indexHTML)) |
| }) |
|
|
| router.GET("/healthz", func(c *gin.Context) { |
| c.JSON(http.StatusOK, gin.H{ |
| "success": true, |
| "message": "ok", |
| "upstream": client.baseURL + openAPIPath, |
| }) |
| }) |
|
|
| router.GET(openAPIPath+"/ping", client.handleProxy(http.MethodGet, "/ping")) |
| router.GET(openAPIPath+"/quota", client.handleProxy(http.MethodGet, "/quota")) |
| router.GET(openAPIPath+"/usage", client.handleProxy(http.MethodGet, "/usage")) |
| router.GET(openAPIPath+"/usage/today", client.handleProxy(http.MethodGet, "/usage/today")) |
| router.GET(openAPIPath+"/resources/:type/:tmdb_id", client.handleProxy(http.MethodGet, "/resources/:type/:tmdb_id")) |
| router.POST(openAPIPath+"/resources/unlock", client.handleUnlock()) |
| router.POST(openAPIPath+"/check/resource", client.handleProxy(http.MethodPost, "/check/resource")) |
|
|
| router.GET(openAPIPath+"/shares", client.handleProxy(http.MethodGet, "/shares")) |
| router.POST(openAPIPath+"/shares", client.handleProxy(http.MethodPost, "/shares")) |
| router.GET(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodGet, "/shares/:slug")) |
| router.PATCH(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodPatch, "/shares/:slug")) |
| router.DELETE(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodDelete, "/shares/:slug")) |
|
|
| router.GET("/api/tmdb/configuration", client.handleTMDBProxy(http.MethodGet, "/configuration")) |
| router.GET("/api/tmdb/configuration/primary_translations", client.handleTMDBProxy(http.MethodGet, "/configuration/primary_translations")) |
| router.GET("/api/tmdb/search/:media_type", client.handleTMDBProxy(http.MethodGet, "/search/:media_type")) |
| router.GET("/api/tmdb/:media_type/:media_id", client.handleTMDBProxy(http.MethodGet, "/:media_type/:media_id")) |
| } |
|
|
| func corsMiddleware() gin.HandlerFunc { |
| return func(c *gin.Context) { |
| headers := c.Writer.Header() |
| headers.Set("Access-Control-Allow-Origin", "*") |
| headers.Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, X-TMDB-API-Key") |
| headers.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") |
| headers.Set("Access-Control-Expose-Headers", "Content-Type, X-RateLimit-Reset, X-Endpoint-Limit, X-Endpoint-Remaining, Retry-After") |
|
|
| if c.Request.Method == http.MethodOptions { |
| c.AbortWithStatus(http.StatusNoContent) |
| return |
| } |
|
|
| c.Next() |
| } |
| } |
|
|
| func (u *upstreamClient) handleProxy(method string, pathTemplate string) gin.HandlerFunc { |
| return func(c *gin.Context) { |
| body, status, headers, err := u.forwardRequest( |
| c.Request.Context(), |
| method, |
| expandPath(pathTemplate, c), |
| copyQuery(c.Request.URL.Query()), |
| readRequestBody(c.Request), |
| u.resolveAPIKey(c.GetHeader("X-API-Key")), |
| c.GetHeader("Content-Type"), |
| ) |
| if err != nil { |
| respondLocalError(c, http.StatusBadGateway, "UPSTREAM_REQUEST_FAILED", err.Error(), "") |
| return |
| } |
|
|
| copyResponseHeaders(c.Writer.Header(), headers) |
| c.Data(status, contentTypeFromHeaders(headers), body) |
| } |
| } |
|
|
| func (u *upstreamClient) handleUnlock() gin.HandlerFunc { |
| return func(c *gin.Context) { |
| rawBody := readRequestBody(c.Request) |
| if len(rawBody) == 0 { |
| respondLocalError(c, http.StatusBadRequest, "400", "request body is required", "请求体不能为空") |
| return |
| } |
|
|
| var req unlockRequest |
| if err := json.Unmarshal(rawBody, &req); err != nil { |
| respondLocalError(c, http.StatusBadRequest, "400", "invalid json body", "请求体 JSON 格式无效") |
| return |
| } |
|
|
| req.Slug = strings.TrimSpace(req.Slug) |
| if req.Slug == "" { |
| respondLocalError(c, http.StatusBadRequest, "400", "slug is required", "缺少 slug") |
| return |
| } |
|
|
| apiKey := u.resolveAPIKey(c.GetHeader("X-API-Key")) |
| if apiKey == "" { |
| respondLocalError(c, http.StatusUnauthorized, "MISSING_API_KEY", "API Key is required", "请通过请求头或环境变量提供 X-API-Key") |
| return |
| } |
|
|
| allowPoints := false |
| if req.AllowPoints != nil { |
| allowPoints = *req.AllowPoints |
| } |
|
|
| if !allowPoints { |
| detail, errResp := u.lookupShareDetailForUnlock(c.Request.Context(), apiKey, req.Slug) |
| if errResp != nil { |
| copyResponseHeaders(c.Writer.Header(), errResp.Headers) |
| c.Data(errResp.StatusCode, contentTypeFromHeaders(errResp.Headers), errResp.Body) |
| return |
| } |
|
|
| if !isSafeToUnlock(detail) { |
| respondLocalError( |
| c, |
| http.StatusConflict, |
| "POINTS_REQUIRED", |
| "resource requires points; set allow_points=true to unlock", |
| "该资源当前需要扣积分。默认不允许扣积分解锁,请显式传 allow_points=true 后再执行。", |
| gin.H{ |
| "slug": detail.Slug, |
| "actual_unlock_points": detail.ActualUnlockPoints, |
| "is_unlocked": detail.IsUnlocked, |
| "is_free_for_user": detail.IsFreeForUser, |
| "unlock_message": detail.UnlockMessage, |
| }, |
| ) |
| return |
| } |
| } |
|
|
| upstreamBody, _ := json.Marshal(gin.H{"slug": req.Slug}) |
| body, status, headers, err := u.forwardRequest( |
| c.Request.Context(), |
| http.MethodPost, |
| "/resources/unlock", |
| nil, |
| upstreamBody, |
| apiKey, |
| "application/json", |
| ) |
| if err != nil { |
| respondLocalError(c, http.StatusBadGateway, "UPSTREAM_REQUEST_FAILED", err.Error(), "") |
| return |
| } |
|
|
| copyResponseHeaders(c.Writer.Header(), headers) |
| c.Data(status, contentTypeFromHeaders(headers), body) |
| } |
| } |
|
|
| func (u *upstreamClient) handleTMDBProxy(method string, pathTemplate string) gin.HandlerFunc { |
| return func(c *gin.Context) { |
| if mediaType := strings.TrimSpace(c.Param("media_type")); mediaType != "" { |
| switch mediaType { |
| case "movie", "tv", "multi": |
| default: |
| respondLocalError(c, http.StatusBadRequest, "400", "invalid media_type", "media_type 只能是 movie、tv 或 multi") |
| return |
| } |
| } |
|
|
| tmdbKey := u.resolveTMDBAPIKey(c.GetHeader("X-TMDB-API-Key")) |
| if tmdbKey == "" { |
| respondLocalError(c, http.StatusUnauthorized, "MISSING_TMDB_API_KEY", "TMDB API Key is required", "请通过请求头 X-TMDB-API-Key 或环境变量 TMDB_API_KEY 提供 TMDB API Key") |
| return |
| } |
|
|
| body, status, headers, err := u.forwardTMDBRequest( |
| c.Request.Context(), |
| method, |
| expandPath(pathTemplate, c), |
| copyQuery(c.Request.URL.Query()), |
| readRequestBody(c.Request), |
| tmdbKey, |
| c.GetHeader("Content-Type"), |
| ) |
| if err != nil { |
| respondLocalError(c, http.StatusBadGateway, "TMDB_REQUEST_FAILED", err.Error(), "") |
| return |
| } |
|
|
| copyResponseHeaders(c.Writer.Header(), headers) |
| c.Data(status, contentTypeFromHeaders(headers), body) |
| } |
| } |
|
|
| type proxyResponse struct { |
| StatusCode int |
| Headers http.Header |
| Body []byte |
| } |
|
|
| func (u *upstreamClient) lookupShareDetailForUnlock(ctx context.Context, apiKey, slug string) (*shareDetailData, *proxyResponse) { |
| normalizedSlug := normalizeSlug(slug) |
| body, status, headers, err := u.forwardRequest( |
| ctx, |
| http.MethodGet, |
| "/shares/"+url.PathEscape(normalizedSlug), |
| nil, |
| nil, |
| apiKey, |
| "", |
| ) |
| if err != nil { |
| return nil, &proxyResponse{ |
| StatusCode: http.StatusBadGateway, |
| Headers: http.Header{"Content-Type": []string{"application/json"}}, |
| Body: mustMarshal(localError{ |
| Success: false, |
| Code: "UPSTREAM_REQUEST_FAILED", |
| Message: err.Error(), |
| }), |
| } |
| } |
|
|
| if status < http.StatusOK || status >= http.StatusMultipleChoices { |
| return nil, &proxyResponse{StatusCode: status, Headers: headers, Body: body} |
| } |
|
|
| var parsed shareDetailResponse |
| if err := json.Unmarshal(body, &parsed); err != nil { |
| return nil, &proxyResponse{ |
| StatusCode: http.StatusBadGateway, |
| Headers: http.Header{"Content-Type": []string{"application/json"}}, |
| Body: mustMarshal(localError{ |
| Success: false, |
| Code: "UPSTREAM_RESPONSE_INVALID", |
| Message: "failed to parse share detail response", |
| Description: "上游 shares detail 接口返回的数据结构无法解析", |
| }), |
| } |
| } |
|
|
| parsed.Data.Slug = normalizedSlug |
| return &parsed.Data, nil |
| } |
|
|
| func (u *upstreamClient) forwardRequest( |
| ctx context.Context, |
| method string, |
| path string, |
| query url.Values, |
| body []byte, |
| apiKey string, |
| contentType string, |
| ) ([]byte, int, http.Header, error) { |
| targetURL := u.baseURL + openAPIPath + path |
| if len(query) > 0 { |
| targetURL += "?" + query.Encode() |
| } |
|
|
| var bodyReader io.Reader |
| if len(body) > 0 { |
| bodyReader = bytes.NewReader(body) |
| } |
|
|
| req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader) |
| if err != nil { |
| return nil, 0, nil, err |
| } |
| req.Header.Set("Accept", "application/json") |
| if apiKey != "" { |
| req.Header.Set("X-API-Key", apiKey) |
| } |
| if len(body) > 0 { |
| if contentType == "" { |
| contentType = "application/json" |
| } |
| req.Header.Set("Content-Type", contentType) |
| } |
|
|
| resp, err := u.httpClient.Do(req) |
| if err != nil { |
| return nil, 0, nil, err |
| } |
| defer resp.Body.Close() |
|
|
| respBody, err := io.ReadAll(resp.Body) |
| if err != nil { |
| return nil, 0, nil, err |
| } |
|
|
| return respBody, resp.StatusCode, resp.Header.Clone(), nil |
| } |
|
|
| func (u *upstreamClient) resolveAPIKey(requestKey string) string { |
| if strings.TrimSpace(requestKey) != "" { |
| return strings.TrimSpace(requestKey) |
| } |
| return u.defaultAPIKey |
| } |
|
|
| func (u *upstreamClient) resolveTMDBAPIKey(requestKey string) string { |
| if strings.TrimSpace(requestKey) != "" { |
| return strings.TrimSpace(requestKey) |
| } |
| return u.defaultTMDBKey |
| } |
|
|
| func (u *upstreamClient) forwardTMDBRequest( |
| ctx context.Context, |
| method string, |
| path string, |
| query url.Values, |
| body []byte, |
| apiKey string, |
| contentType string, |
| ) ([]byte, int, http.Header, error) { |
| targetQuery := copyQuery(query) |
| if targetQuery == nil { |
| targetQuery = make(url.Values) |
| } |
| targetQuery.Set("api_key", apiKey) |
|
|
| targetURL := u.tmdbBaseURL + path |
| if len(targetQuery) > 0 { |
| targetURL += "?" + targetQuery.Encode() |
| } |
|
|
| var bodyReader io.Reader |
| if len(body) > 0 { |
| bodyReader = bytes.NewReader(body) |
| } |
|
|
| req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader) |
| if err != nil { |
| return nil, 0, nil, err |
| } |
| req.Header.Set("Accept", "application/json") |
| if len(body) > 0 { |
| if contentType == "" { |
| contentType = "application/json" |
| } |
| req.Header.Set("Content-Type", contentType) |
| } |
|
|
| resp, err := u.httpClient.Do(req) |
| if err != nil { |
| return nil, 0, nil, err |
| } |
| defer resp.Body.Close() |
|
|
| respBody, err := io.ReadAll(resp.Body) |
| if err != nil { |
| return nil, 0, nil, err |
| } |
|
|
| return respBody, resp.StatusCode, resp.Header.Clone(), nil |
| } |
|
|
| func expandPath(template string, c *gin.Context) string { |
| result := template |
| for _, key := range []string{"type", "tmdb_id", "slug", "media_type", "media_id"} { |
| result = strings.ReplaceAll(result, ":"+key, url.PathEscape(c.Param(key))) |
| } |
| return result |
| } |
|
|
| func copyQuery(values url.Values) url.Values { |
| if values == nil { |
| return nil |
| } |
| cloned := make(url.Values, len(values)) |
| for key, items := range values { |
| cloned[key] = append([]string(nil), items...) |
| } |
| return cloned |
| } |
|
|
| func readRequestBody(req *http.Request) []byte { |
| if req == nil || req.Body == nil { |
| return nil |
| } |
| body, err := io.ReadAll(req.Body) |
| if err != nil { |
| return nil |
| } |
| req.Body = io.NopCloser(bytes.NewReader(body)) |
| return body |
| } |
|
|
| func normalizeSlug(slug string) string { |
| replacer := strings.NewReplacer("-", "", " ", "") |
| return strings.ToLower(strings.TrimSpace(replacer.Replace(slug))) |
| } |
|
|
| func isSafeToUnlock(detail *shareDetailData) bool { |
| if detail == nil { |
| return false |
| } |
| if detail.IsUnlocked || detail.IsFreeForUser { |
| return true |
| } |
| return detail.ActualUnlockPoints <= 0 |
| } |
|
|
| func normalizeTMDBBaseURL(value string) string { |
| normalized := strings.TrimSpace(value) |
| if normalized == "" { |
| normalized = defaultTMDBBaseURL |
| } |
| normalized = strings.TrimSuffix(normalized, "/") |
| if strings.HasSuffix(normalized, "/3") { |
| return normalized |
| } |
| return normalized + "/3" |
| } |
|
|
| func copyResponseHeaders(dst, src http.Header) { |
| if dst == nil || src == nil { |
| return |
| } |
| for _, key := range []string{ |
| "Content-Type", |
| "X-RateLimit-Reset", |
| "X-Endpoint-Limit", |
| "X-Endpoint-Remaining", |
| "Retry-After", |
| } { |
| values := src.Values(key) |
| if len(values) == 0 { |
| continue |
| } |
| dst.Del(key) |
| for _, value := range values { |
| dst.Add(key, value) |
| } |
| } |
| } |
|
|
| func contentTypeFromHeaders(headers http.Header) string { |
| if headers == nil { |
| return "application/json" |
| } |
| if value := headers.Get("Content-Type"); value != "" { |
| return value |
| } |
| return "application/json" |
| } |
|
|
| func respondLocalError(c *gin.Context, status int, code, message, description string, data ...interface{}) { |
| resp := localError{ |
| Success: false, |
| Code: code, |
| Message: message, |
| Description: description, |
| } |
| if len(data) > 0 { |
| resp.Data = data[0] |
| } |
| c.JSON(status, resp) |
| } |
|
|
| func mustMarshal(v interface{}) []byte { |
| data, err := json.Marshal(v) |
| if err != nil { |
| panic(err) |
| } |
| return data |
| } |
|
|
| func init() { |
| gin.SetMode(resolveGinMode()) |
| } |
|
|
| func resolveGinMode() string { |
| mode := strings.TrimSpace(os.Getenv("GIN_MODE")) |
| switch mode { |
| case gin.DebugMode, gin.ReleaseMode, gin.TestMode: |
| return mode |
| case "": |
| return gin.ReleaseMode |
| default: |
| return gin.ReleaseMode |
| } |
| } |
|
|