diff --git a/http/context.go b/http/context.go index 5b923bc7..5a6723c6 100644 --- a/http/context.go +++ b/http/context.go @@ -23,6 +23,7 @@ const ( FILESTATS MIRRORSTATS CHECKSUM + METALINK UNDEFINED SecureOption = iota WITHTLS @@ -40,6 +41,7 @@ type Context struct { isMirrorStats bool isFileStats bool isChecksum bool + isMetalink bool isPretty bool secureOption SecureOption } @@ -60,6 +62,9 @@ func NewContext(w http.ResponseWriter, r *http.Request, t Templates) *Context { } else if c.paramBool("md5") || c.paramBool("sha1") || c.paramBool("sha256") { c.typ = CHECKSUM c.isChecksum = true + } else if c.paramBool("meta4") || strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/metalink4+xml") { + c.typ = METALINK + c.isMetalink = true } else { c.typ = STANDARD } @@ -129,6 +134,11 @@ func (c *Context) IsChecksum() bool { return c.isChecksum } +// IsMetalink returns true if a Metalink 4 (RFC 5854) document has been requested +func (c *Context) IsMetalink() bool { + return c.isMetalink +} + // IsPretty returns true if the pretty json has been requested func (c *Context) IsPretty() bool { return c.isPretty diff --git a/http/http.go b/http/http.go index 4b970c63..3c897153 100644 --- a/http/http.go +++ b/http/http.go @@ -210,6 +210,8 @@ func (h *HTTP) requestDispatcher(w http.ResponseWriter, r *http.Request) { switch ctx.Type() { case MIRRORLIST: fallthrough + case METALINK: + fallthrough case STANDARD: h.mirrorHandler(w, r, ctx) case MIRRORSTATS: @@ -318,6 +320,8 @@ func (h *HTTP) mirrorHandler(w http.ResponseWriter, r *http.Request, ctx *Contex if ctx.IsMirrorlist() { resultRenderer = &MirrorListRenderer{} + } else if ctx.IsMetalink() { + resultRenderer = &MetalinkRenderer{} } else { switch GetConfig().OutputMode { case "json": diff --git a/http/pagerenderer.go b/http/pagerenderer.go index 44f3f05f..c7782d4b 100644 --- a/http/pagerenderer.go +++ b/http/pagerenderer.go @@ -6,14 +6,18 @@ package http import ( "bytes" "encoding/json" + "encoding/xml" "errors" "fmt" "net/http" + "path/filepath" "sort" "strconv" "strings" + "time" . "github.com/etix/mirrorbits/config" + "github.com/etix/mirrorbits/core" "github.com/etix/mirrorbits/mirrors" ) @@ -100,6 +104,109 @@ func (w *RedirectRenderer) Write(ctx *Context, results *mirrors.Results) (status return http.StatusNotFound, nil } +// Metalink 4.0 (RFC 5854) document structures. The XML namespace on the root +// element produces the required xmlns="urn:ietf:params:xml:ns:metalink". +type metalink struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:metalink metalink"` + Generator string `xml:"generator,omitempty"` + Published string `xml:"published,omitempty"` + Files []metalinkFile `xml:"file"` +} + +type metalinkFile struct { + Name string `xml:"name,attr"` + Size int64 `xml:"size,omitempty"` + Hashes []metalinkHash `xml:"hash,omitempty"` + URLs []metalinkURL `xml:"url"` +} + +type metalinkHash struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` +} + +type metalinkURL struct { + Location string `xml:"location,attr,omitempty"` + Priority int `xml:"priority,attr,omitempty"` + Value string `xml:",chardata"` +} + +// MetalinkRenderer renders a Metalink 4.0 (RFC 5854) document listing the +// candidate mirrors for the requested file, ordered by preference, so that the +// client (aria2, wget2, ...) can perform the final mirror selection and +// failover itself instead of being redirected to a single mirror. +type MetalinkRenderer struct{} + +// Type returns the type of renderer +func (w *MetalinkRenderer) Type() string { + return "METALINK" +} + +// Write is used to write the result to the ResponseWriter +func (w *MetalinkRenderer) Write(ctx *Context, results *mirrors.Results) (statusCode int, err error) { + if len(results.MirrorList) == 0 { + // No mirror returned for this request + http.NotFound(ctx.ResponseWriter(), ctx.Request()) + return http.StatusNotFound, nil + } + + // Path of the file relative to the repository root (no leading slash), + // used to build the per-mirror URLs. + path := strings.TrimPrefix(results.FileInfo.Path, "/") + + file := metalinkFile{ + // The "name" is the file basename, not the full path. Metalink clients + // (e.g. librepo) may match it by exact string equality, and it is also + // what aria2 uses as the output filename. + Name: filepath.Base(path), + Size: results.FileInfo.Size, + } + + // Source file hashes, identical across all mirrors. Hash type names follow + // the IANA registry as required by RFC 5854. + if results.FileInfo.Sha256 != "" { + file.Hashes = append(file.Hashes, metalinkHash{Type: "sha-256", Value: results.FileInfo.Sha256}) + } + if results.FileInfo.Sha1 != "" { + file.Hashes = append(file.Hashes, metalinkHash{Type: "sha-1", Value: results.FileInfo.Sha1}) + } + if results.FileInfo.Md5 != "" { + file.Hashes = append(file.Hashes, metalinkHash{Type: "md5", Value: results.FileInfo.Md5}) + } + + // Candidate mirrors, already ordered by preference by the selection engine. + // priority 1 is the most preferred (RFC 5854 ยง4.1.6). + for i, m := range results.MirrorList { + var location string + if len(m.CountryFields) > 0 { + location = strings.ToLower(m.CountryFields[0]) + } + file.URLs = append(file.URLs, metalinkURL{ + Location: location, + Priority: i + 1, + Value: m.AbsoluteURL + path, + }) + } + + doc := metalink{ + Generator: "mirrorbits/" + core.VERSION, + Published: time.Now().UTC().Format(time.RFC3339), + Files: []metalinkFile{file}, + } + + output, err := xml.MarshalIndent(doc, "", " ") + if err != nil { + return http.StatusInternalServerError, err + } + + ctx.ResponseWriter().Header().Set("Content-Type", "application/metalink4+xml; charset=utf-8") + ctx.ResponseWriter().Header().Set("Content-Length", strconv.Itoa(len(xml.Header)+len(output))) + ctx.ResponseWriter().Write([]byte(xml.Header)) + ctx.ResponseWriter().Write(output) + + return http.StatusOK, nil +} + // MirrorListRenderer is used to render the mirrorlist page using the HTML templates type MirrorListRenderer struct{} diff --git a/http/selection.go b/http/selection.go index ad312e55..d5354890 100644 --- a/http/selection.go +++ b/http/selection.go @@ -57,8 +57,10 @@ func (h DefaultEngine) Selection(ctx *Context, cache *mirrors.Cache, fileInfo *f mlist[i], mlist[j] = mlist[j], mlist[i] } - // Shortcut - if !ctx.IsMirrorlist() { + // Shortcut: the redirect/json path only needs a handful of mirrors, + // but mirrorlist and metalink want the full candidate list so the + // client can fail over across all of them. + if !ctx.IsMirrorlist() && !ctx.IsMetalink() { // Reduce the number of mirrors to process mlist = mlist[:utils.Min(5, len(mlist))] } @@ -121,7 +123,7 @@ func (h DefaultEngine) Selection(ctx *Context, cache *mirrors.Cache, fileInfo *f if selected > 1 { - if ctx.IsMirrorlist() { + if ctx.IsMirrorlist() || ctx.IsMetalink() { // Don't reorder the results, just set the percentage for i := 0; i < selected; i++ { id := mlist[i].ID