update
This commit is contained in:
@@ -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`)。
|
||||
|
||||
333
internal/hubmodule/composer/hooks.go
Normal file
333
internal/hubmodule/composer/hooks.go
Normal 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
|
||||
}
|
||||
43
internal/hubmodule/composer/hooks_test.go
Normal file
43
internal/hubmodule/composer/hooks_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
105
internal/hubmodule/docker/hooks.go
Normal file
105
internal/hubmodule/docker/hooks.go
Normal 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
|
||||
}
|
||||
31
internal/hubmodule/docker/hooks_test.go
Normal file
31
internal/hubmodule/docker/hooks_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
27
internal/hubmodule/golang/hooks.go
Normal file
27
internal/hubmodule/golang/hooks.go
Normal 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
|
||||
}
|
||||
19
internal/hubmodule/golang/hooks_test.go
Normal file
19
internal/hubmodule/golang/hooks_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
28
internal/hubmodule/golang/module.go
Normal file
28
internal/hubmodule/golang/module.go
Normal 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"),
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
26
internal/hubmodule/npm/hooks.go
Normal file
26
internal/hubmodule/npm/hooks.go
Normal 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
|
||||
}
|
||||
22
internal/hubmodule/npm/hooks_test.go
Normal file
22
internal/hubmodule/npm/hooks_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
215
internal/hubmodule/pypi/hooks.go
Normal file
215
internal/hubmodule/pypi/hooks.go
Normal 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
|
||||
}
|
||||
}
|
||||
42
internal/hubmodule/pypi/hooks_test.go
Normal file
42
internal/hubmodule/pypi/hooks_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
22
internal/hubmodule/template/hook_example_test.go
Normal file
22
internal/hubmodule/template/hook_example_test.go
Normal 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
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
73
internal/hubmodule/template/module_test.go
Normal file
73
internal/hubmodule/template/module_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user