packages.wenpai.net/internal/http/proxy.go
elementary c93b25df7d
Some checks failed
ci / Go Modules Tidy (push) Waiting to run
ci / Go Test (push) Waiting to run
ci / Integration Test (push) Waiting to run
govulncheck / govulncheck (push) Waiting to run
ci / GolangCI-Lint (push) Has been cancelled
refactor(http): proxy Composer metadata and versions from upstream
- Add proxy.go with upstreamCache + fetchUpstreamJSON
- Proxy /packages.json, /p2/*, and /metadata/changes.json to wp-packages.org
- Rewrite notify-batch and metadata-changes-url in packages.json
- Refactor handleDetail to fetch version list from upstream p2 JSON
- Remove obsolete composer.go and changes.go (local DB-backed handlers)
2026-04-12 01:24:50 +08:00

167 lines
4.9 KiB
Go

package http
import (
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/roots/wp-packages/internal/app"
)
// upstreamCache holds cached upstream JSON responses.
type upstreamCache struct {
mu sync.RWMutex
entries map[string]cacheEntry
}
type cacheEntry struct {
data []byte
expiresAt time.Time
}
var p2Cache = &upstreamCache{entries: make(map[string]cacheEntry)}
func (c *upstreamCache) get(key string) ([]byte, bool) {
c.mu.RLock()
ent, ok := c.entries[key]
c.mu.RUnlock()
if !ok {
return nil, false
}
if time.Now().After(ent.expiresAt) {
c.mu.Lock()
delete(c.entries, key)
c.mu.Unlock()
return nil, false
}
return ent.data, true
}
func (c *upstreamCache) set(key string, data []byte, ttl time.Duration) {
c.mu.Lock()
c.entries[key] = cacheEntry{data: data, expiresAt: time.Now().Add(ttl)}
c.mu.Unlock()
}
func fetchUpstreamJSON(url string, maxAge time.Duration) ([]byte, error) {
if data, ok := p2Cache.get(url); ok {
return data, nil
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "packages.wenpai.net/1.0 (mirror)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, http.ErrNoLocation
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
p2Cache.set(url, body, maxAge)
return body, nil
}
func rewritePackagesJSON(body []byte, appURL string) []byte {
s := string(body)
s = strings.ReplaceAll(s, "\"notify-batch\": \"https://wp-packages.org/downloads\"", "\"notify-batch\": \""+appURL+"/downloads\"")
s = strings.ReplaceAll(s, "\"notify-batch\":\"https://wp-packages.org/downloads\"", "\"notify-batch\":\""+appURL+"/downloads\"")
s = strings.ReplaceAll(s, "\"metadata-changes-url\": \"https://wp-packages.org/metadata/changes.json\"", "\"metadata-changes-url\": \""+appURL+"/metadata/changes.json\"")
s = strings.ReplaceAll(s, "\"metadata-changes-url\":\"https://wp-packages.org/metadata/changes.json\"", "\"metadata-changes-url\":\""+appURL+"/metadata/changes.json\"")
return []byte(s)
}
// handlePackagesJSON serves the root Composer repository descriptor by proxying upstream.
func handlePackagesJSON(a *app.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := fetchUpstreamJSON("https://wp-packages.org/packages.json", 5*time.Minute)
if err != nil {
a.Logger.Error("proxying packages.json", "error", err)
http.Error(w, "upstream unavailable", http.StatusBadGateway)
return
}
body = rewritePackagesJSON(body, a.Config.AppURL)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write(body)
}
}
// handleP2Package serves Composer p2 metadata by proxying upstream.
func handleP2Package(a *app.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vendor := r.PathValue("vendor")
file := r.PathValue("file")
if file == "" || !strings.HasSuffix(file, ".json") {
http.NotFound(w, r)
return
}
upstreamURL := "https://wp-packages.org/p2/" + vendor + "/" + file
body, err := fetchUpstreamJSON(upstreamURL, 15*time.Minute)
if err != nil {
// If upstream 404, pass it through
a.Logger.Error("proxying p2", "url", upstreamURL, "error", err)
http.Error(w, "upstream unavailable", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=900")
_, _ = w.Write(body)
}
}
// fetchUpstreamVersions returns the version strings for a package by requesting upstream p2 metadata.
func fetchUpstreamVersions(pkgType, name string) ([]string, error) {
url := "https://wp-packages.org/p2/wp-" + pkgType + "/" + name + ".json"
body, err := fetchUpstreamJSON(url, 15*time.Minute)
if err != nil {
return nil, err
}
var resp struct {
Packages map[string]map[string]json.RawMessage `json:"packages"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, err
}
pkgKey := "wp-" + pkgType + "/" + name
versions := make([]string, 0, len(resp.Packages[pkgKey]))
for v := range resp.Packages[pkgKey] {
versions = append(versions, v)
}
return versions, nil
}
// handleMetadataChanges proxies the Composer v2 metadata changes endpoint.
func handleMetadataChanges(a *app.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := r.URL.RawQuery
upstreamURL := "https://wp-packages.org/metadata/changes.json"
if query != "" {
upstreamURL += "?" + query
}
body, err := fetchUpstreamJSON(upstreamURL, 5*time.Minute)
if err != nil {
a.Logger.Error("proxying metadata changes", "error", err)
http.Error(w, "upstream unavailable", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write(body)
}
}