537 lines
13 KiB
Go
537 lines
13 KiB
Go
package composer
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/any-hub/any-hub/internal/proxy/hooks"
|
|
)
|
|
|
|
var composerDistRegistry sync.Map
|
|
|
|
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 trimmed := trimComposerNamespace(clean); trimmed != clean {
|
|
clean = trimmed
|
|
}
|
|
if isComposerDistPath(clean) {
|
|
return clean, nil
|
|
}
|
|
return clean, rawQuery
|
|
}
|
|
|
|
func resolveDistUpstream(ctx *hooks.RequestContext, _ string, clean string, rawQuery []byte) string {
|
|
domain := ""
|
|
if ctx != nil {
|
|
domain = ctx.Domain
|
|
}
|
|
if target := resolveComposerMirrorDist(domain, clean); target != "" {
|
|
return target
|
|
}
|
|
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) {
|
|
cleanPath := trimComposerNamespace(path)
|
|
switch {
|
|
case cleanPath == "/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(cleanPath):
|
|
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) {
|
|
var root map[string]any
|
|
if err := json.Unmarshal(body, &root); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
changed := false
|
|
if rewriteComposerRootURLField(root, "metadata-url", domain) {
|
|
changed = true
|
|
}
|
|
if rewriteComposerRootURLField(root, "providers-url", domain) {
|
|
changed = true
|
|
}
|
|
if ensureComposerMirrors(root, domain) {
|
|
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 rewriteComposerRootURLField(root map[string]any, key string, domain string) bool {
|
|
value, ok := root[key].(string)
|
|
if !ok || value == "" {
|
|
return false
|
|
}
|
|
proxied := buildComposerProxyURL(value, domain)
|
|
if proxied == value {
|
|
return false
|
|
}
|
|
root[key] = proxied
|
|
return true
|
|
}
|
|
|
|
func ensureComposerMirrors(root map[string]any, domain string) bool {
|
|
domain = strings.TrimSpace(domain)
|
|
if domain == "" {
|
|
return false
|
|
}
|
|
target := "https://" + domain + "/dists/%package%/%reference%.%type%"
|
|
if existing, ok := root["mirrors"].([]any); ok && len(existing) == 1 {
|
|
if entry, ok := existing[0].(map[string]any); ok {
|
|
distURL, _ := entry["dist-url"].(string)
|
|
preferred, _ := entry["preferred"].(bool)
|
|
if distURL == target && preferred {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
root["mirrors"] = []map[string]any{
|
|
{
|
|
"dist-url": target,
|
|
"preferred": true,
|
|
},
|
|
}
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
reference, _ := distVal["reference"].(string)
|
|
distType, _ := distVal["type"].(string)
|
|
if packageName != "" && domain != "" && reference != "" && distType != "" {
|
|
registerComposerDist(domain, packageName, reference, distType, urlValue)
|
|
}
|
|
rewritten := rewriteComposerLegacyDistURL(urlValue, domain)
|
|
if rewritten == urlValue {
|
|
return changed
|
|
}
|
|
distVal["url"] = rewritten
|
|
return true
|
|
}
|
|
|
|
func rewriteComposerLegacyDistURL(original string, domain string) string {
|
|
trimmed := strings.TrimSpace(original)
|
|
if trimmed == "" {
|
|
return original
|
|
}
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil {
|
|
return original
|
|
}
|
|
if domain != "" && strings.EqualFold(parsed.Host, domain) && strings.HasPrefix(parsed.Path, "/dist/") {
|
|
// Already rewritten.
|
|
return original
|
|
}
|
|
if parsed.Scheme == "" || parsed.Host == "" {
|
|
return original
|
|
}
|
|
pathVal := parsed.Path
|
|
if raw := parsed.RawPath; raw != "" {
|
|
pathVal = raw
|
|
}
|
|
if !strings.HasPrefix(pathVal, "/") {
|
|
pathVal = "/" + pathVal
|
|
}
|
|
var builder strings.Builder
|
|
builder.WriteString("/dist/")
|
|
builder.WriteString(parsed.Scheme)
|
|
builder.WriteString("/")
|
|
builder.WriteString(parsed.Host)
|
|
builder.WriteString(pathVal)
|
|
if parsed.RawQuery != "" {
|
|
builder.WriteString("?")
|
|
builder.WriteString(parsed.RawQuery)
|
|
}
|
|
proxiedPath := builder.String()
|
|
if domain == "" {
|
|
return proxiedPath
|
|
}
|
|
return buildComposerProxyURL(proxiedPath, domain)
|
|
}
|
|
|
|
func isComposerMetadataPath(path string) bool {
|
|
clean := trimComposerNamespace(path)
|
|
switch {
|
|
case clean == "/packages.json":
|
|
return true
|
|
case strings.HasPrefix(clean, "/p2/"):
|
|
return true
|
|
case strings.HasPrefix(clean, "/p/"):
|
|
return true
|
|
case strings.HasPrefix(clean, "/provider-"):
|
|
return true
|
|
case strings.HasPrefix(clean, "/providers/"):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isComposerDistPath(path string) bool {
|
|
clean := trimComposerNamespace(path)
|
|
if strings.HasPrefix(clean, "/dist/") {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(clean, "/dists/")
|
|
}
|
|
|
|
func parseComposerDistURL(path string, rawQuery string) (*url.URL, bool) {
|
|
clean := trimComposerNamespace(path)
|
|
if !strings.HasPrefix(clean, "/dist/") {
|
|
return nil, false
|
|
}
|
|
trimmed := strings.TrimPrefix(clean, "/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
|
|
}
|
|
|
|
func stripPackagistHost(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
raw = strings.ReplaceAll(raw, "https://repo.packagist.org", "")
|
|
raw = strings.ReplaceAll(raw, "http://repo.packagist.org", "")
|
|
return raw
|
|
}
|
|
|
|
func isPackagistHost(host string) bool {
|
|
return strings.EqualFold(host, "repo.packagist.org")
|
|
}
|
|
|
|
func buildComposerProxyURL(raw string, domain string) string {
|
|
trimmed := stripPackagistHost(strings.TrimSpace(raw))
|
|
if trimmed == "" {
|
|
return trimmed
|
|
}
|
|
if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" {
|
|
if domain != "" && strings.EqualFold(parsed.Host, domain) {
|
|
return trimmed
|
|
}
|
|
if !isPackagistHost(parsed.Host) {
|
|
return trimmed
|
|
}
|
|
if path := parsed.EscapedPath(); path != "" {
|
|
trimmed = path
|
|
if parsed.RawQuery != "" {
|
|
trimmed = trimmed + "?" + parsed.RawQuery
|
|
}
|
|
}
|
|
}
|
|
|
|
if !strings.HasPrefix(trimmed, "/") {
|
|
trimmed = "/" + trimmed
|
|
}
|
|
|
|
if domain == "" {
|
|
return trimmed
|
|
}
|
|
return "https://" + domain + trimmed
|
|
}
|
|
|
|
func resolveComposerMirrorDist(domain string, locator string) string {
|
|
domain = strings.TrimSpace(domain)
|
|
if domain == "" {
|
|
return ""
|
|
}
|
|
pkg, reference, distType, ok := parseComposerMirrorDistLocator(locator)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
target, ok := lookupComposerDist(domain, pkg, reference, distType)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return target
|
|
}
|
|
|
|
func parseComposerMirrorDistLocator(locator string) (string, string, string, bool) {
|
|
clean := trimComposerNamespace(locator)
|
|
if !strings.HasPrefix(clean, "/dists/") {
|
|
return "", "", "", false
|
|
}
|
|
trimmed := strings.TrimPrefix(clean, "/dists/")
|
|
lastSlash := strings.LastIndex(trimmed, "/")
|
|
if lastSlash <= 0 || lastSlash >= len(trimmed)-1 {
|
|
return "", "", "", false
|
|
}
|
|
packagePart := trimmed[:lastSlash]
|
|
file := trimmed[lastSlash+1:]
|
|
if packagePart == "" || file == "" {
|
|
return "", "", "", false
|
|
}
|
|
ext := path.Ext(file)
|
|
if ext == "" {
|
|
return "", "", "", false
|
|
}
|
|
reference := strings.TrimSuffix(file, ext)
|
|
distType := strings.TrimPrefix(ext, ".")
|
|
if reference == "" || distType == "" {
|
|
return "", "", "", false
|
|
}
|
|
packageName := strings.ToLower(strings.Trim(packagePart, "/"))
|
|
if packageName == "" {
|
|
return "", "", "", false
|
|
}
|
|
return packageName, reference, distType, true
|
|
}
|
|
|
|
func registerComposerDist(domain string, packageName string, reference string, distType string, upstream string) {
|
|
key := composerDistKey(domain, packageName, reference, distType)
|
|
if key == "" || strings.TrimSpace(upstream) == "" {
|
|
return
|
|
}
|
|
composerDistRegistry.Store(key, upstream)
|
|
}
|
|
|
|
func lookupComposerDist(domain string, packageName string, reference string, distType string) (string, bool) {
|
|
key := composerDistKey(domain, packageName, reference, distType)
|
|
if key == "" {
|
|
return "", false
|
|
}
|
|
value, ok := composerDistRegistry.Load(key)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
str, _ := value.(string)
|
|
if strings.TrimSpace(str) == "" {
|
|
return "", false
|
|
}
|
|
return str, true
|
|
}
|
|
|
|
func composerDistKey(domain string, packageName string, reference string, distType string) string {
|
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
|
pkg := strings.ToLower(strings.TrimSpace(packageName))
|
|
ref := strings.TrimSpace(reference)
|
|
typ := strings.ToLower(strings.TrimSpace(distType))
|
|
if domain == "" || pkg == "" || ref == "" || typ == "" {
|
|
return ""
|
|
}
|
|
return domain + "|" + pkg + "|" + ref + "|" + typ
|
|
}
|
|
|
|
func trimComposerNamespace(p string) string {
|
|
if strings.HasPrefix(p, "/composer/") {
|
|
return strings.TrimPrefix(p, "/composer")
|
|
}
|
|
if p == "/composer" {
|
|
return "/"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func resetComposerDistRegistry() {
|
|
composerDistRegistry.Range(func(key, _ any) bool {
|
|
composerDistRegistry.Delete(key)
|
|
return true
|
|
})
|
|
}
|