This commit is contained in:
2025-11-17 15:39:44 +08:00
parent abfa51f12e
commit 1ddda89499
46 changed files with 2185 additions and 751 deletions

View File

@@ -24,7 +24,7 @@ internal/hubmodule/
2. 填写模块特有逻辑与缓存策略,并确保包含中文注释解释设计。
3. 在模块目录添加 `module_test.go`,使用 `httptest.Server``t.TempDir()` 复现真实流量。
4. 运行 `make modules-test` 验证模块单元测试。
5. 更新 `config.toml` 中对应 `[[Hub]].Module` 字段,验证集成测试后再提交
5. `[[Hub]].Module` 留空时会优先选择与 `Type` 同名的模块,实际迁移时仍建议显式填写,便于 diagnostics 标记 rollout
## 术语
- **Module Key**:模块唯一标识(如 `legacy``npm-tarball`)。

View File

@@ -0,0 +1,333 @@
package composer
import (
"encoding/json"
"net/url"
"strings"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func init() {
hooks.MustRegister("composer", hooks.Hooks{
NormalizePath: normalizePath,
ResolveUpstream: resolveDistUpstream,
RewriteResponse: rewriteResponse,
CachePolicy: cachePolicy,
ContentType: contentType,
})
}
func normalizePath(_ *hooks.RequestContext, clean string, rawQuery []byte) (string, []byte) {
if isComposerDistPath(clean) {
return clean, nil
}
return clean, rawQuery
}
func resolveDistUpstream(_ *hooks.RequestContext, _ string, clean string, rawQuery []byte) string {
if !isComposerDistPath(clean) {
return ""
}
target, ok := parseComposerDistURL(clean, string(rawQuery))
if !ok {
return ""
}
return target.String()
}
func rewriteResponse(
ctx *hooks.RequestContext,
status int,
headers map[string]string,
body []byte,
path string,
) (int, map[string]string, []byte, error) {
switch {
case path == "/packages.json":
data, changed, err := rewriteComposerRootBody(body, ctx.Domain)
if err != nil {
return status, headers, body, err
}
if !changed {
return status, headers, body, nil
}
outHeaders := ensureJSONHeaders(headers)
return status, outHeaders, data, nil
case isComposerMetadataPath(path):
data, changed, err := rewriteComposerMetadata(body, ctx.Domain)
if err != nil {
return status, headers, body, err
}
if !changed {
return status, headers, body, nil
}
outHeaders := ensureJSONHeaders(headers)
return status, outHeaders, data, nil
default:
return status, headers, body, nil
}
}
func ensureJSONHeaders(headers map[string]string) map[string]string {
if headers == nil {
headers = map[string]string{}
}
headers["Content-Type"] = "application/json"
delete(headers, "Content-Encoding")
delete(headers, "Etag")
return headers
}
func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.CachePolicy) hooks.CachePolicy {
switch {
case isComposerDistPath(locatorPath):
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = false
case isComposerMetadataPath(locatorPath):
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = true
default:
current.AllowCache = false
current.AllowStore = false
current.RequireRevalidate = false
}
return current
}
func contentType(_ *hooks.RequestContext, locatorPath string) string {
if isComposerMetadataPath(locatorPath) {
return "application/json"
}
return ""
}
func rewriteComposerRootBody(body []byte, domain string) ([]byte, bool, error) {
type root struct {
Packages map[string]string `json:"packages"`
}
var payload root
if err := json.Unmarshal(body, &payload); err != nil {
return nil, false, err
}
if len(payload.Packages) == 0 {
return body, false, nil
}
changed := false
for key, value := range payload.Packages {
rewritten := rewriteComposerAbsolute(domain, value)
if rewritten != value {
payload.Packages[key] = rewritten
changed = true
}
}
if !changed {
return body, false, nil
}
data, err := json.Marshal(payload)
if err != nil {
return nil, false, err
}
return data, true, nil
}
func rewriteComposerMetadata(body []byte, domain string) ([]byte, bool, error) {
type packagesRoot struct {
Packages map[string]json.RawMessage `json:"packages"`
}
var root packagesRoot
if err := json.Unmarshal(body, &root); err != nil {
return nil, false, err
}
if len(root.Packages) == 0 {
return body, false, nil
}
changed := false
for name, raw := range root.Packages {
updated, rewritten, err := rewriteComposerPackagesPayload(raw, domain, name)
if err != nil {
return nil, false, err
}
if rewritten {
root.Packages[name] = updated
changed = true
}
}
if !changed {
return body, false, nil
}
data, err := json.Marshal(root)
if err != nil {
return nil, false, err
}
return data, true, nil
}
func rewriteComposerPackagesPayload(raw json.RawMessage, domain string, packageName string) (json.RawMessage, bool, error) {
var asArray []map[string]any
if err := json.Unmarshal(raw, &asArray); err == nil {
rewrote := rewriteComposerVersionSlice(asArray, domain, packageName)
if !rewrote {
return raw, false, nil
}
data, err := json.Marshal(asArray)
return data, true, err
}
var asMap map[string]map[string]any
if err := json.Unmarshal(raw, &asMap); err == nil {
rewrote := rewriteComposerVersionMap(asMap, domain, packageName)
if !rewrote {
return raw, false, nil
}
data, err := json.Marshal(asMap)
return data, true, err
}
return raw, false, nil
}
func rewriteComposerVersionSlice(items []map[string]any, domain string, packageName string) bool {
changed := false
for _, entry := range items {
if rewriteComposerVersion(entry, domain, packageName) {
changed = true
}
}
return changed
}
func rewriteComposerVersionMap(items map[string]map[string]any, domain string, packageName string) bool {
changed := false
for _, entry := range items {
if rewriteComposerVersion(entry, domain, packageName) {
changed = true
}
}
return changed
}
func rewriteComposerVersion(entry map[string]any, domain string, packageName string) bool {
if entry == nil {
return false
}
changed := false
if packageName != "" {
if name, _ := entry["name"].(string); strings.TrimSpace(name) == "" {
entry["name"] = packageName
changed = true
}
}
distVal, ok := entry["dist"].(map[string]any)
if !ok {
return changed
}
urlValue, ok := distVal["url"].(string)
if !ok || urlValue == "" {
return changed
}
rewritten := rewriteComposerDistURL(domain, urlValue)
if rewritten == urlValue {
return changed
}
distVal["url"] = rewritten
return true
}
func rewriteComposerDistURL(domain, original string) string {
parsed, err := url.Parse(original)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return original
}
prefix := "/dist/" + parsed.Scheme + "/" + parsed.Host
newURL := url.URL{
Scheme: "https",
Host: domain,
Path: prefix + parsed.Path,
RawQuery: parsed.RawQuery,
Fragment: parsed.Fragment,
}
if raw := parsed.RawPath; raw != "" {
newURL.RawPath = prefix + raw
}
return newURL.String()
}
func rewriteComposerAbsolute(domain, raw string) string {
if raw == "" {
return raw
}
if strings.HasPrefix(raw, "//") {
return "https://" + domain + strings.TrimPrefix(raw, "//")
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
parsed.Host = domain
parsed.Scheme = "https"
return parsed.String()
}
pathVal := raw
if !strings.HasPrefix(pathVal, "/") {
pathVal = "/" + pathVal
}
return "https://" + domain + pathVal
}
func isComposerMetadataPath(path string) bool {
switch {
case path == "/packages.json":
return true
case strings.HasPrefix(path, "/p2/"):
return true
case strings.HasPrefix(path, "/p/"):
return true
case strings.HasPrefix(path, "/provider-"):
return true
case strings.HasPrefix(path, "/providers/"):
return true
default:
return false
}
}
func isComposerDistPath(path string) bool {
return strings.HasPrefix(path, "/dist/")
}
func parseComposerDistURL(path string, rawQuery string) (*url.URL, bool) {
if !strings.HasPrefix(path, "/dist/") {
return nil, false
}
trimmed := strings.TrimPrefix(path, "/dist/")
parts := strings.SplitN(trimmed, "/", 3)
if len(parts) < 3 {
return nil, false
}
scheme := parts[0]
host := parts[1]
rest := parts[2]
if scheme == "" || host == "" {
return nil, false
}
if rest == "" {
rest = "/"
} else {
rest = "/" + rest
}
target := &url.URL{
Scheme: scheme,
Host: host,
Path: rest,
RawPath: rest,
}
if rawQuery != "" {
target.RawQuery = rawQuery
}
return target, true
}

View File

@@ -0,0 +1,43 @@
package composer
import (
"strings"
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func TestNormalizePathDropsDistQuery(t *testing.T) {
path, raw := normalizePath(nil, "/dist/https/example.com/file.zip", []byte("token=1"))
if raw != nil {
t.Fatalf("expected query to be dropped")
}
if path != "/dist/https/example.com/file.zip" {
t.Fatalf("unexpected path %s", path)
}
}
func TestResolveDistUpstream(t *testing.T) {
url := resolveDistUpstream(nil, "", "/dist/https/example.com/file.zip", []byte("token=1"))
if url != "https://example.com/file.zip?token=1" {
t.Fatalf("unexpected upstream %s", url)
}
}
func TestRewriteResponseUpdatesURLs(t *testing.T) {
ctx := &hooks.RequestContext{Domain: "cache.example"}
body := []byte(`{"packages":{"a/b":{"1.0.0":{"dist":{"url":"https://pkg.example/dist.zip"}}}}}`)
_, headers, rewritten, err := rewriteResponse(ctx, 200, map[string]string{}, body, "/p2/a/b.json")
if err != nil {
t.Fatalf("rewrite failed: %v", err)
}
if string(rewritten) == string(body) {
t.Fatalf("expected rewrite to modify payload")
}
if headers["Content-Type"] != "application/json" {
t.Fatalf("expected json content type")
}
if !strings.Contains(string(rewritten), "https://cache.example/dist/https/pkg.example/dist.zip") {
t.Fatalf("expected rewritten URL, got %s", string(rewritten))
}
}

View File

@@ -0,0 +1,105 @@
package docker
import (
"strings"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func init() {
hooks.MustRegister("docker", hooks.Hooks{
NormalizePath: normalizePath,
CachePolicy: cachePolicy,
ContentType: contentType,
})
}
func normalizePath(ctx *hooks.RequestContext, clean string, rawQuery []byte) (string, []byte) {
if !isDockerHubHost(ctx.UpstreamHost) {
return clean, rawQuery
}
repo, rest, ok := splitDockerRepoPath(clean)
if !ok || repo == "" || strings.Contains(repo, "/") || repo == "library" {
return clean, rawQuery
}
return "/v2/library/" + repo + rest, rawQuery
}
func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.CachePolicy) hooks.CachePolicy {
clean := locatorPath
if clean == "/v2" || clean == "v2" || clean == "/v2/" {
return hooks.CachePolicy{}
}
if strings.Contains(clean, "/_catalog") {
return hooks.CachePolicy{}
}
if isDockerImmutablePath(clean) {
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = false
return current
}
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = true
return current
}
func contentType(_ *hooks.RequestContext, locatorPath string) string {
switch {
case strings.Contains(locatorPath, "/tags/list"):
return "application/json"
case strings.Contains(locatorPath, "/blobs/"):
return "application/octet-stream"
default:
return ""
}
}
func isDockerHubHost(host string) bool {
switch strings.ToLower(host) {
case "registry-1.docker.io", "docker.io", "index.docker.io":
return true
default:
return false
}
}
func splitDockerRepoPath(path string) (string, string, bool) {
if !strings.HasPrefix(path, "/v2/") {
return "", "", false
}
suffix := strings.TrimPrefix(path, "/v2/")
if suffix == "" || suffix == "/" {
return "", "", false
}
segments := strings.Split(suffix, "/")
var repoSegments []string
for i, seg := range segments {
if seg == "" {
return "", "", false
}
switch seg {
case "manifests", "blobs", "tags", "referrers":
if len(repoSegments) == 0 {
return "", "", false
}
rest := "/" + strings.Join(segments[i:], "/")
return strings.Join(repoSegments, "/"), rest, true
case "_catalog":
return "", "", false
}
repoSegments = append(repoSegments, seg)
}
return "", "", false
}
func isDockerImmutablePath(path string) bool {
if strings.Contains(path, "/blobs/sha256:") {
return true
}
if strings.Contains(path, "/manifests/sha256:") {
return true
}
return false
}

View File

@@ -0,0 +1,31 @@
package docker
import (
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func TestNormalizePathAddsLibraryForDockerHub(t *testing.T) {
ctx := &hooks.RequestContext{UpstreamHost: "registry-1.docker.io"}
path, _ := normalizePath(ctx, "/v2/nginx/manifests/latest", nil)
if path != "/v2/library/nginx/manifests/latest" {
t.Fatalf("expected library namespace, got %s", path)
}
path, _ = normalizePath(ctx, "/v2/library/nginx/manifests/latest", nil)
if path != "/v2/library/nginx/manifests/latest" {
t.Fatalf("unexpected rewrite for existing namespace")
}
}
func TestSplitDockerRepoPath(t *testing.T) {
repo, rest, ok := splitDockerRepoPath("/v2/library/nginx/manifests/latest")
if !ok || repo != "library/nginx" || rest != "/manifests/latest" {
t.Fatalf("unexpected split result repo=%s rest=%s ok=%v", repo, rest, ok)
}
if _, _, ok := splitDockerRepoPath("/v2/_catalog"); ok {
t.Fatalf("expected catalog path to be ignored")
}
}

View File

@@ -0,0 +1,27 @@
package golang
import "github.com/any-hub/any-hub/internal/proxy/hooks"
import "strings"
func init() {
hooks.MustRegister("go", hooks.Hooks{
CachePolicy: cachePolicy,
})
}
func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.CachePolicy) hooks.CachePolicy {
if strings.Contains(locatorPath, "/@v/") &&
(strings.HasSuffix(locatorPath, ".zip") ||
strings.HasSuffix(locatorPath, ".mod") ||
strings.HasSuffix(locatorPath, ".info")) {
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = false
return current
}
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = true
return current
}

View File

@@ -0,0 +1,19 @@
package golang
import (
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func TestCachePolicyForModuleFiles(t *testing.T) {
policy := cachePolicy(nil, "/example/@v/v1.0.0.zip", hooks.CachePolicy{})
if !policy.AllowCache || policy.RequireRevalidate {
t.Fatalf("expected immutable go artifacts to be cacheable without revalidate")
}
policy = cachePolicy(nil, "/example/@latest", hooks.CachePolicy{})
if !policy.RequireRevalidate {
t.Fatalf("expected non-artifacts to require revalidate")
}
}

View File

@@ -0,0 +1,28 @@
package golang
import (
"time"
"github.com/any-hub/any-hub/internal/hubmodule"
)
const goDefaultTTL = 30 * time.Minute
func init() {
hubmodule.MustRegister(hubmodule.ModuleMetadata{
Key: "go",
Description: "Go module proxy with sumdb/cache defaults",
MigrationState: hubmodule.MigrationStateBeta,
SupportedProtocols: []string{
"go",
},
CacheStrategy: hubmodule.CacheStrategyProfile{
TTLHint: goDefaultTTL,
ValidationMode: hubmodule.ValidationModeLastModified,
DiskLayout: "raw_path",
RequiresMetadataFile: false,
SupportsStreamingWrite: true,
},
LocatorRewrite: hubmodule.DefaultLocatorRewrite("go"),
})
}

View File

@@ -3,7 +3,7 @@ package legacy
import "github.com/any-hub/any-hub/internal/hubmodule"
// 模块描述:包装当前共享的代理 + 缓存实现,供未迁移的 Hub 使用。
// 模块描述:包装当前共享的代理 + 缓存实现,供未迁移的 Hub 使用,并在 diagnostics 中标记为 legacy-only
func init() {
hubmodule.MustRegister(hubmodule.ModuleMetadata{
Key: hubmodule.DefaultModuleKey(),

View File

@@ -0,0 +1,26 @@
package npm
import (
"strings"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func init() {
hooks.MustRegister("npm", hooks.Hooks{
CachePolicy: cachePolicy,
})
}
func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.CachePolicy) hooks.CachePolicy {
if strings.Contains(locatorPath, "/-/") && strings.HasSuffix(locatorPath, ".tgz") {
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = false
return current
}
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = true
return current
}

View File

@@ -0,0 +1,22 @@
package npm
import (
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func TestCachePolicyForTarball(t *testing.T) {
policy := cachePolicy(nil, "/pkg/-/pkg-1.0.0.tgz", hooks.CachePolicy{})
if policy.RequireRevalidate {
t.Fatalf("tarball should not require revalidate")
}
if !policy.AllowCache {
t.Fatalf("tarball should allow cache")
}
policy = cachePolicy(nil, "/pkg", hooks.CachePolicy{})
if !policy.RequireRevalidate {
t.Fatalf("metadata should require revalidate")
}
}

View File

@@ -0,0 +1,215 @@
package pypi
import (
"bytes"
"encoding/json"
"net/url"
"strings"
"golang.org/x/net/html"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func init() {
hooks.MustRegister("pypi", hooks.Hooks{
NormalizePath: normalizePath,
ResolveUpstream: resolveFilesUpstream,
RewriteResponse: rewriteResponse,
CachePolicy: cachePolicy,
ContentType: contentType,
})
}
func normalizePath(_ *hooks.RequestContext, clean string, rawQuery []byte) (string, []byte) {
if strings.HasPrefix(clean, "/files/") || strings.HasPrefix(clean, "/simple/") {
return ensureSimpleTrailingSlash(clean), rawQuery
}
if isDistributionAsset(clean) {
return clean, rawQuery
}
trimmed := strings.Trim(clean, "/")
if trimmed == "" || strings.HasPrefix(trimmed, "_") {
return clean, rawQuery
}
if !strings.HasSuffix(trimmed, "/") {
trimmed += "/"
}
return "/simple/" + trimmed, rawQuery
}
func ensureSimpleTrailingSlash(path string) string {
if !strings.HasPrefix(path, "/simple/") {
return path
}
if strings.HasSuffix(path, "/") {
return path
}
return path + "/"
}
func resolveFilesUpstream(_ *hooks.RequestContext, baseURL string, clean string, rawQuery []byte) string {
if !strings.HasPrefix(clean, "/files/") {
return ""
}
trimmed := strings.TrimPrefix(clean, "/files/")
parts := strings.SplitN(trimmed, "/", 3)
if len(parts) < 3 {
return ""
}
scheme := parts[0]
host := parts[1]
rest := parts[2]
if scheme == "" || host == "" {
return ""
}
target := url.URL{Scheme: scheme, Host: host, Path: "/" + strings.TrimPrefix(rest, "/")}
if len(rawQuery) > 0 {
target.RawQuery = string(rawQuery)
}
return target.String()
}
func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.CachePolicy) hooks.CachePolicy {
if isDistributionAsset(locatorPath) {
current.AllowCache = true
current.AllowStore = true
current.RequireRevalidate = false
return current
}
current.RequireRevalidate = true
return current
}
func contentType(_ *hooks.RequestContext, locatorPath string) string {
if strings.Contains(locatorPath, "/simple/") {
return "text/html"
}
return ""
}
func rewriteResponse(
ctx *hooks.RequestContext,
status int,
headers map[string]string,
body []byte,
path string,
) (int, map[string]string, []byte, error) {
if !strings.HasPrefix(path, "/simple") && path != "/" {
return status, headers, body, nil
}
domain := ctx.Domain
rewritten, contentType, err := rewritePyPIBody(body, headers["Content-Type"], domain)
if err != nil {
return status, headers, body, err
}
if headers == nil {
headers = map[string]string{}
}
if contentType != "" {
headers["Content-Type"] = contentType
}
delete(headers, "Content-Encoding")
return status, headers, rewritten, nil
}
func rewritePyPIBody(body []byte, contentType string, domain string) ([]byte, string, error) {
lowerCT := strings.ToLower(contentType)
if strings.Contains(lowerCT, "application/vnd.pypi.simple.v1+json") || strings.HasPrefix(strings.TrimSpace(string(body)), "{") {
data := map[string]interface{}{}
if err := json.Unmarshal(body, &data); err != nil {
return body, contentType, err
}
if files, ok := data["files"].([]interface{}); ok {
for _, entry := range files {
if fileMap, ok := entry.(map[string]interface{}); ok {
if urlValue, ok := fileMap["url"].(string); ok {
fileMap["url"] = rewritePyPIFileURL(domain, urlValue)
}
}
}
}
rewriteBytes, err := json.Marshal(data)
if err != nil {
return body, contentType, err
}
return rewriteBytes, "application/vnd.pypi.simple.v1+json", nil
}
rewrittenHTML, err := rewritePyPIHTML(body, domain)
if err != nil {
return body, contentType, err
}
return rewrittenHTML, "text/html; charset=utf-8", nil
}
func rewritePyPIHTML(body []byte, domain string) ([]byte, error) {
node, err := html.Parse(bytes.NewReader(body))
if err != nil {
return nil, err
}
rewriteHTMLNode(node, domain)
var buf bytes.Buffer
if err := html.Render(&buf, node); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func rewriteHTMLNode(n *html.Node, domain string) {
if n.Type == html.ElementNode {
rewriteHTMLAttributes(n, domain)
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
rewriteHTMLNode(child, domain)
}
}
func rewriteHTMLAttributes(n *html.Node, domain string) {
for i, attr := range n.Attr {
switch attr.Key {
case "href", "data-dist-info-metadata", "data-core-metadata":
if strings.HasPrefix(attr.Val, "http://") || strings.HasPrefix(attr.Val, "https://") {
n.Attr[i].Val = rewritePyPIFileURL(domain, attr.Val)
}
}
}
}
func rewritePyPIFileURL(domain, original string) string {
parsed, err := url.Parse(original)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return original
}
prefix := "/files/" + parsed.Scheme + "/" + parsed.Host
newURL := url.URL{
Scheme: "https",
Host: domain,
Path: prefix + parsed.Path,
RawQuery: parsed.RawQuery,
Fragment: parsed.Fragment,
}
if raw := parsed.RawPath; raw != "" {
newURL.RawPath = prefix + raw
}
return newURL.String()
}
func isDistributionAsset(path string) bool {
switch {
case strings.HasSuffix(path, ".whl"):
return true
case strings.HasSuffix(path, ".tar.gz"):
return true
case strings.HasSuffix(path, ".tar.bz2"):
return true
case strings.HasSuffix(path, ".tgz"):
return true
case strings.HasSuffix(path, ".zip"):
return true
case strings.HasSuffix(path, ".egg"):
return true
default:
return false
}
}

View File

@@ -0,0 +1,42 @@
package pypi
import (
"strings"
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
func TestNormalizePathAddsSimplePrefix(t *testing.T) {
ctx := &hooks.RequestContext{HubType: "pypi"}
path, _ := normalizePath(ctx, "/requests", nil)
if path != "/simple/requests/" {
t.Fatalf("expected /simple prefix, got %s", path)
}
}
func TestResolveFilesUpstream(t *testing.T) {
ctx := &hooks.RequestContext{}
target := resolveFilesUpstream(ctx, "", "/files/https/example.com/pkg.tgz", nil)
if target != "https://example.com/pkg.tgz" {
t.Fatalf("unexpected upstream target: %s", target)
}
}
func TestRewriteResponseAdjustsLinks(t *testing.T) {
ctx := &hooks.RequestContext{Domain: "cache.example"}
body := []byte(`<html><body><a href="https://files.pythonhosted.org/package.whl">link</a></body></html>`)
_, headers, rewritten, err := rewriteResponse(ctx, 200, map[string]string{"Content-Type": "text/html"}, body, "/simple/requests/")
if err != nil {
t.Fatalf("rewrite failed: %v", err)
}
if string(rewritten) == string(body) {
t.Fatalf("expected rewrite to modify HTML")
}
if headers["Content-Type"] == "" {
t.Fatalf("expected content type to be set")
}
if !strings.Contains(string(rewritten), "/files/https/files.pythonhosted.org/package.whl") {
t.Fatalf("expected rewritten link, got %s", string(rewritten))
}
}

View File

@@ -0,0 +1,22 @@
package template
import (
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
// This test acts as a usage example for module authors.
func TestExampleHookDefinition(t *testing.T) {
h := hooks.Hooks{
NormalizePath: func(ctx *hooks.RequestContext, clean string, rawQuery []byte) (string, []byte) {
return clean, rawQuery
},
CachePolicy: func(ctx *hooks.RequestContext, path string, current hooks.CachePolicy) hooks.CachePolicy {
current.AllowCache = true
current.AllowStore = true
return current
},
}
_ = h
}

View File

@@ -2,11 +2,11 @@
package template
import "github.com/any-hub/any-hub/internal/hubmodule"
//
// 使用方式:复制整个目录到 internal/hubmodule/<module-key>/ 并替换字段。
// - 将 TemplateModule 重命名为实际模块类型。
// - 在 init() 中调用 hubmodule.MustRegister注册新的 ModuleMetadata。
// - 在模块目录中实现自定义代理/缓存逻辑,然后在 main 中调用 proxy.RegisterModuleHandler
// - 在 init() 中调用 hubmodule.MustRegister 注册新的 ModuleMetadata。
// - 在模块目录中实现自定义 Hook见 hook_example_test.go 中的示例),然后在 main/init 中调用 hooks.MustRegister + proxy.RegisterModule。
//
// 注意:本文件仅示例 metadata 注册写法,不会参与编译。
var _ = hubmodule.ModuleMetadata{}

View File

@@ -0,0 +1,73 @@
package template
import (
"net/http"
"testing"
"github.com/any-hub/any-hub/internal/proxy/hooks"
)
// This test shows a full hook lifecycle that module authors can copy when creating a new hook.
func TestTemplateHookFlow(t *testing.T) {
baseURL := "https://example.com"
ctx := &hooks.RequestContext{
HubName: "demo",
ModuleKey: "template",
}
h := hooks.Hooks{
NormalizePath: func(_ *hooks.RequestContext, clean string, rawQuery []byte) (string, []byte) {
return "/normalized" + clean, rawQuery
},
ResolveUpstream: func(_ *hooks.RequestContext, upstream string, clean string, rawQuery []byte) string {
if len(rawQuery) > 0 {
return upstream + clean + "?" + string(rawQuery)
}
return upstream + clean
},
CachePolicy: func(_ *hooks.RequestContext, path string, current hooks.CachePolicy) hooks.CachePolicy {
current.AllowCache = path != ""
current.AllowStore = true
return current
},
ContentType: func(_ *hooks.RequestContext, path string) string {
if path == "/normalized/index.json" {
return "application/json"
}
return ""
},
RewriteResponse: func(_ *hooks.RequestContext, status int, headers map[string]string, body []byte, _ string) (int, map[string]string, []byte, error) {
if headers == nil {
headers = map[string]string{}
}
headers["X-Demo"] = "ok"
return status, headers, body, nil
},
}
normalized, _ := h.NormalizePath(ctx, "/index.json", nil)
if normalized != "/normalized/index.json" {
t.Fatalf("expected normalized path, got %s", normalized)
}
u := h.ResolveUpstream(ctx, baseURL, normalized, nil)
if u != baseURL+normalized {
t.Fatalf("expected upstream %s, got %s", baseURL+normalized, u)
}
policy := h.CachePolicy(ctx, normalized, hooks.CachePolicy{})
if !policy.AllowCache || !policy.AllowStore {
t.Fatalf("expected policy to allow cache/store, got %#v", policy)
}
status, headers, body, err := h.RewriteResponse(ctx, http.StatusOK, map[string]string{}, []byte("ok"), normalized)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if headers["X-Demo"] != "ok" {
t.Fatalf("expected rewrite to set header, got %s", headers["X-Demo"])
}
if status != http.StatusOK || string(body) != "ok" {
t.Fatalf("expected unchanged status/body, got %d/%s", status, string(body))
}
if ct := h.ContentType(ctx, normalized); ct != "application/json" {
t.Fatalf("expected content type application/json, got %s", ct)
}
}