From 5f837efc15f3d1e0d7fbed7fc569251143266584 Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Tue, 23 Jan 2024 22:42:46 +0100
Subject: [PATCH 1/6] feat(nuget): basic manifest download

---
 modules/context/base.go             |  10 +++
 modules/packages/nuget/metadata.go  | 100 ++++++++++++++++++++--------
 routers/api/packages/nuget/nuget.go |  63 ++++++++++++------
 3 files changed, 127 insertions(+), 46 deletions(-)

diff --git a/modules/context/base.go b/modules/context/base.go
index 8df1dde866..625fd2680c 100644
--- a/modules/context/base.go
+++ b/modules/context/base.go
@@ -5,6 +5,7 @@ package context
 
 import (
 	"context"
+	"encoding/xml"
 	"fmt"
 	"io"
 	"net/http"
@@ -136,6 +137,15 @@ func (b *Base) JSON(status int, content any) {
 	}
 }
 
+// XML render content as XML
+func (b *Base) XML(status int, content any) {
+	b.Resp.Header().Set("Content-Type", "application/xml;charset=utf-8")
+	b.Resp.WriteHeader(status)
+	if err := xml.NewEncoder(b.Resp).Encode(content); err != nil {
+		log.Error("Render XML failed: %v", err)
+	}
+}
+
 // RemoteAddr returns the client machine ip address
 func (b *Base) RemoteAddr() string {
 	return b.Req.RemoteAddr
diff --git a/modules/packages/nuget/metadata.go b/modules/packages/nuget/metadata.go
index 3c478b1c02..e7a4d2c0ed 100644
--- a/modules/packages/nuget/metadata.go
+++ b/modules/packages/nuget/metadata.go
@@ -71,34 +71,47 @@ type Dependency struct {
 	Version string `json:"version"`
 }
 
+type nuspecPackageType struct {
+	Name string `xml:"name,attr"`
+}
+
+type nuspecPackageTypes struct {
+	PackageType []nuspecPackageType `xml:"packageType"`
+}
+
+type nuspecRepository struct {
+	URL string `xml:"url,attr"`
+}
+type nuspecDependency struct {
+	ID      string `xml:"id,attr"`
+	Version string `xml:"version,attr"`
+	Exclude string `xml:"exclude,attr"`
+}
+
+type nuspecGroup struct {
+	TargetFramework string             `xml:"targetFramework,attr"`
+	Dependency      []nuspecDependency `xml:"dependency"`
+}
+
+type nuspecDependencies struct {
+	Group []nuspecGroup `xml:"group"`
+}
+
+type nuspeceMetadata struct {
+	ID                       string             `xml:"id"`
+	Version                  string             `xml:"version"`
+	Authors                  string             `xml:"authors"`
+	RequireLicenseAcceptance bool               `xml:"requireLicenseAcceptance"`
+	ProjectURL               string             `xml:"projectUrl"`
+	Description              string             `xml:"description"`
+	ReleaseNotes             string             `xml:"releaseNotes"`
+	PackageTypes             nuspecPackageTypes `xml:"packageTypes"`
+	Repository               nuspecRepository   `xml:"repository"`
+	Dependencies             nuspecDependencies `xml:"dependencies"`
+}
+
 type nuspecPackage struct {
-	Metadata struct {
-		ID                       string `xml:"id"`
-		Version                  string `xml:"version"`
-		Authors                  string `xml:"authors"`
-		RequireLicenseAcceptance bool   `xml:"requireLicenseAcceptance"`
-		ProjectURL               string `xml:"projectUrl"`
-		Description              string `xml:"description"`
-		ReleaseNotes             string `xml:"releaseNotes"`
-		PackageTypes             struct {
-			PackageType []struct {
-				Name string `xml:"name,attr"`
-			} `xml:"packageType"`
-		} `xml:"packageTypes"`
-		Repository struct {
-			URL string `xml:"url,attr"`
-		} `xml:"repository"`
-		Dependencies struct {
-			Group []struct {
-				TargetFramework string `xml:"targetFramework,attr"`
-				Dependency      []struct {
-					ID      string `xml:"id,attr"`
-					Version string `xml:"version,attr"`
-					Exclude string `xml:"exclude,attr"`
-				} `xml:"dependency"`
-			} `xml:"group"`
-		} `xml:"dependencies"`
-	} `xml:"metadata"`
+	Metadata nuspeceMetadata `xml:"metadata"`
 }
 
 // ParsePackageMetaData parses the metadata of a Nuget package file
@@ -204,3 +217,36 @@ func toNormalizedVersion(v *version.Version) string {
 	}
 	return buf.String()
 }
+
+// returning any here because we use a private type and we don't need the type for xml marshalling
+func GenerateNuspec(pd *Package) any {
+	m := nuspeceMetadata{
+		ID:                       pd.ID,
+		Version:                  pd.Version,
+		Authors:                  pd.Metadata.Authors,
+		ProjectURL:               pd.Metadata.ProjectURL,
+		Repository:               nuspecRepository{URL: pd.Metadata.RepositoryURL},
+		RequireLicenseAcceptance: pd.Metadata.RequireLicenseAcceptance,
+		Dependencies: nuspecDependencies{
+			Group: make([]nuspecGroup, len(pd.Metadata.Dependencies)),
+		},
+	}
+
+	for tgf, deps := range pd.Metadata.Dependencies {
+		gDeps := make([]nuspecDependency, len(deps))
+		for i, dep := range deps {
+			gDeps[i] = nuspecDependency{
+				ID:      dep.ID,
+				Version: dep.Version,
+			}
+		}
+		m.Dependencies.Group = append(m.Dependencies.Group, nuspecGroup{
+			TargetFramework: tgf,
+			Dependency:      gDeps,
+		})
+	}
+
+	return &nuspecPackage{
+		Metadata: m,
+	}
+}
diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go
index 769c4c1824..1ba102bfff 100644
--- a/routers/api/packages/nuget/nuget.go
+++ b/routers/api/packages/nuget/nuget.go
@@ -387,34 +387,59 @@ func EnumeratePackageVersionsV3(ctx *context.Context) {
 	ctx.JSON(http.StatusOK, resp)
 }
 
-// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
+// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-manifest-nuspec
+// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
 func DownloadPackageFile(ctx *context.Context) {
 	packageName := ctx.Params("id")
 	packageVersion := ctx.Params("version")
 	filename := ctx.Params("filename")
 
-	s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
-		ctx,
-		&packages_service.PackageInfo{
-			Owner:       ctx.Package.Owner,
-			PackageType: packages_model.TypeNuGet,
-			Name:        packageName,
-			Version:     packageVersion,
-		},
-		&packages_service.PackageFileInfo{
-			Filename: filename,
-		},
-	)
-	if err != nil {
-		if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+	if filename == fmt.Sprintf("%s.nuspec", packageName) {
+		pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeNuGet, packageName, packageVersion)
+		if err != nil {
 			apiError(ctx, http.StatusNotFound, err)
 			return
 		}
-		apiError(ctx, http.StatusInternalServerError, err)
-		return
-	}
 
-	helper.ServePackageFile(ctx, s, u, pf)
+		pd, err := packages_model.GetPackageDescriptor(ctx, pv)
+		if err != nil {
+			apiError(ctx, http.StatusInternalServerError, err)
+			return
+		}
+		pkg := &nuget_module.Package{
+			ID:       pd.Package.Name,
+			Version:  packageVersion,
+			Metadata: pd.Metadata.(*nuget_module.Metadata),
+		}
+
+		ctx.XML(http.StatusOK, nuget_module.GenerateNuspec(pkg))
+	} else if filename == fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion) {
+
+		s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+			ctx,
+			&packages_service.PackageInfo{
+				Owner:       ctx.Package.Owner,
+				PackageType: packages_model.TypeNuGet,
+				Name:        packageName,
+				Version:     packageVersion,
+			},
+			&packages_service.PackageFileInfo{
+				Filename: filename,
+			},
+		)
+		if err != nil {
+			if err == packages_model.ErrPackageNotExist || err == packages_model.ErrPackageFileNotExist {
+				apiError(ctx, http.StatusNotFound, err)
+				return
+			}
+			apiError(ctx, http.StatusInternalServerError, err)
+			return
+		}
+
+		helper.ServePackageFile(ctx, s, u, pf)
+	} else {
+		apiError(ctx, http.StatusInternalServerError, "Invalid filename")
+	}
 }
 
 // UploadPackage creates a new package with the metadata contained in the uploaded nupgk file

From a715984a42be9da81c48106d5eae244098ac1108 Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Wed, 24 Jan 2024 00:12:36 +0100
Subject: [PATCH 2/6] fix: write xml header

---
 modules/context/base.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/modules/context/base.go b/modules/context/base.go
index 625fd2680c..5b1cb14a82 100644
--- a/modules/context/base.go
+++ b/modules/context/base.go
@@ -141,6 +141,7 @@ func (b *Base) JSON(status int, content any) {
 func (b *Base) XML(status int, content any) {
 	b.Resp.Header().Set("Content-Type", "application/xml;charset=utf-8")
 	b.Resp.WriteHeader(status)
+	b.Resp.Write([]byte(xml.Header))
 	if err := xml.NewEncoder(b.Resp).Encode(content); err != nil {
 		log.Error("Render XML failed: %v", err)
 	}

From 6ea6895a3616246e7282aa20d8f010fa931b60ea Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Wed, 24 Jan 2024 00:13:22 +0100
Subject: [PATCH 3/6] fix: optional elements and xml schema

---
 modules/packages/nuget/metadata.go | 113 +++++++++++++++++------------
 1 file changed, 68 insertions(+), 45 deletions(-)

diff --git a/modules/packages/nuget/metadata.go b/modules/packages/nuget/metadata.go
index e7a4d2c0ed..b32151cdf5 100644
--- a/modules/packages/nuget/metadata.go
+++ b/modules/packages/nuget/metadata.go
@@ -80,12 +80,13 @@ type nuspecPackageTypes struct {
 }
 
 type nuspecRepository struct {
-	URL string `xml:"url,attr"`
+	URL  string `xml:"url,attr,omitempty"`
+	Type string `xml:"type,attr,omitempty"`
 }
 type nuspecDependency struct {
 	ID      string `xml:"id,attr"`
 	Version string `xml:"version,attr"`
-	Exclude string `xml:"exclude,attr"`
+	Exclude string `xml:"exclude,attr,omitempty"`
 }
 
 type nuspecGroup struct {
@@ -98,19 +99,21 @@ type nuspecDependencies struct {
 }
 
 type nuspeceMetadata struct {
-	ID                       string             `xml:"id"`
-	Version                  string             `xml:"version"`
-	Authors                  string             `xml:"authors"`
-	RequireLicenseAcceptance bool               `xml:"requireLicenseAcceptance"`
-	ProjectURL               string             `xml:"projectUrl"`
-	Description              string             `xml:"description"`
-	ReleaseNotes             string             `xml:"releaseNotes"`
-	PackageTypes             nuspecPackageTypes `xml:"packageTypes"`
-	Repository               nuspecRepository   `xml:"repository"`
-	Dependencies             nuspecDependencies `xml:"dependencies"`
+	ID                       string              `xml:"id"`
+	Version                  string              `xml:"version"`
+	Authors                  string              `xml:"authors"`
+	RequireLicenseAcceptance bool                `xml:"requireLicenseAcceptance,omitempty"`
+	ProjectURL               string              `xml:"projectUrl,omitempty"`
+	Description              string              `xml:"description"`
+	ReleaseNotes             string              `xml:"releaseNotes,omitempty"`
+	PackageTypes             *nuspecPackageTypes `xml:"packageTypes,omitempty"`
+	Repository               *nuspecRepository   `xml:"repository,omitempty"`
+	Dependencies             *nuspecDependencies `xml:"dependencies,omitempty"`
 }
 
 type nuspecPackage struct {
+	XMLName  xml.Name        `xml:"package"`
+	Xmlns    string          `xml:"xmlns,attr"`
 	Metadata nuspeceMetadata `xml:"metadata"`
 }
 
@@ -162,10 +165,12 @@ func ParseNuspecMetaData(r io.Reader) (*Package, error) {
 	}
 
 	packageType := DependencyPackage
-	for _, pt := range p.Metadata.PackageTypes.PackageType {
-		if pt.Name == "SymbolsPackage" {
-			packageType = SymbolsPackage
-			break
+	if p.Metadata.PackageTypes != nil {
+		for _, pt := range p.Metadata.PackageTypes.PackageType {
+			if pt.Name == "SymbolsPackage" {
+				packageType = SymbolsPackage
+				break
+			}
 		}
 	}
 
@@ -174,24 +179,27 @@ func ParseNuspecMetaData(r io.Reader) (*Package, error) {
 		ReleaseNotes:             p.Metadata.ReleaseNotes,
 		Authors:                  p.Metadata.Authors,
 		ProjectURL:               p.Metadata.ProjectURL,
-		RepositoryURL:            p.Metadata.Repository.URL,
 		RequireLicenseAcceptance: p.Metadata.RequireLicenseAcceptance,
 		Dependencies:             make(map[string][]Dependency),
 	}
-
-	for _, group := range p.Metadata.Dependencies.Group {
-		deps := make([]Dependency, 0, len(group.Dependency))
-		for _, dep := range group.Dependency {
-			if dep.ID == "" || dep.Version == "" {
-				continue
+	if p.Metadata.Repository != nil {
+		m.RepositoryURL = p.Metadata.Repository.URL
+	}
+	if p.Metadata.Dependencies != nil {
+		for _, group := range p.Metadata.Dependencies.Group {
+			deps := make([]Dependency, 0, len(group.Dependency))
+			for _, dep := range group.Dependency {
+				if dep.ID == "" || dep.Version == "" {
+					continue
+				}
+				deps = append(deps, Dependency{
+					ID:      dep.ID,
+					Version: dep.Version,
+				})
+			}
+			if len(deps) > 0 {
+				m.Dependencies[group.TargetFramework] = deps
 			}
-			deps = append(deps, Dependency{
-				ID:      dep.ID,
-				Version: dep.Version,
-			})
-		}
-		if len(deps) > 0 {
-			m.Dependencies[group.TargetFramework] = deps
 		}
 	}
 	return &Package{
@@ -224,29 +232,44 @@ func GenerateNuspec(pd *Package) any {
 		ID:                       pd.ID,
 		Version:                  pd.Version,
 		Authors:                  pd.Metadata.Authors,
+		Description:              pd.Metadata.Description,
 		ProjectURL:               pd.Metadata.ProjectURL,
-		Repository:               nuspecRepository{URL: pd.Metadata.RepositoryURL},
 		RequireLicenseAcceptance: pd.Metadata.RequireLicenseAcceptance,
-		Dependencies: nuspecDependencies{
-			Group: make([]nuspecGroup, len(pd.Metadata.Dependencies)),
-		},
 	}
 
-	for tgf, deps := range pd.Metadata.Dependencies {
-		gDeps := make([]nuspecDependency, len(deps))
-		for i, dep := range deps {
-			gDeps[i] = nuspecDependency{
-				ID:      dep.ID,
-				Version: dep.Version,
-			}
+	if pd.Metadata.RepositoryURL != "" {
+		m.Repository = &nuspecRepository{
+			URL: pd.Metadata.RepositoryURL,
+		}
+	}
+
+	groups := len(pd.Metadata.Dependencies)
+	if groups > 0 {
+		m.Dependencies = &nuspecDependencies{
+			Group: make([]nuspecGroup, 0, groups),
+		}
+
+		for tgf, deps := range pd.Metadata.Dependencies {
+			if len(deps) == 0 {
+				continue
+			}
+			gDeps := make([]nuspecDependency, 0, len(deps))
+			for _, dep := range deps {
+				gDeps = append(gDeps, nuspecDependency{
+					ID:      dep.ID,
+					Version: dep.Version,
+				})
+			}
+
+			m.Dependencies.Group = append(m.Dependencies.Group, nuspecGroup{
+				TargetFramework: tgf,
+				Dependency:      gDeps,
+			})
 		}
-		m.Dependencies.Group = append(m.Dependencies.Group, nuspecGroup{
-			TargetFramework: tgf,
-			Dependency:      gDeps,
-		})
 	}
 
 	return &nuspecPackage{
+		Xmlns:    "http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd",
 		Metadata: m,
 	}
 }

From 9bfc74833a3b657453b4519573598432a87e3e3c Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Wed, 24 Jan 2024 00:13:47 +0100
Subject: [PATCH 4/6] fix: pass all other requests to file search

---
 routers/api/packages/nuget/nuget.go | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go
index 1ba102bfff..bd495d5cc8 100644
--- a/routers/api/packages/nuget/nuget.go
+++ b/routers/api/packages/nuget/nuget.go
@@ -413,8 +413,7 @@ func DownloadPackageFile(ctx *context.Context) {
 		}
 
 		ctx.XML(http.StatusOK, nuget_module.GenerateNuspec(pkg))
-	} else if filename == fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion) {
-
+	} else {
 		s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
 			ctx,
 			&packages_service.PackageInfo{
@@ -437,8 +436,6 @@ func DownloadPackageFile(ctx *context.Context) {
 		}
 
 		helper.ServePackageFile(ctx, s, u, pf)
-	} else {
-		apiError(ctx, http.StatusInternalServerError, "Invalid filename")
 	}
 }
 

From b798f4ce86daa78e694c5c142e6f5f44938e6cb6 Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Wed, 24 Jan 2024 00:14:04 +0100
Subject: [PATCH 5/6] test: add integration test

---
 tests/integration/api_packages_nuget_test.go | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go
index 20dafd5cc7..eb67693010 100644
--- a/tests/integration/api_packages_nuget_test.go
+++ b/tests/integration/api_packages_nuget_test.go
@@ -353,6 +353,21 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
 
 		assert.Equal(t, content, resp.Body.Bytes())
 
+		req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.nuspec", url, packageName, packageVersion, packageName)).
+			AddBasicAuth(user.Name)
+		resp = MakeRequest(t, req, http.StatusOK)
+
+		nuspec := `<?xml version="1.0" encoding="UTF-8"?>` + "\n" +
+			`<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd"><metadata>` +
+			`<id>` + packageName + `</id><version>` + packageVersion + `</version><authors>` + packageAuthors + `</authors><description>` + packageDescription + `</description>` +
+			`<dependencies><group targetFramework=".NETStandard2.0">` +
+			// https://github.com/golang/go/issues/21399 go can't generate self-closing tags
+			`<dependency id="Microsoft.CSharp" version="4.5.0"></dependency>` +
+			`</group></dependencies>` +
+			`</metadata></package>`
+
+		assert.Equal(t, nuspec, resp.Body.String())
+
 		checkDownloadCount(1)
 
 		req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.snupkg", url, packageName, packageVersion, packageName, packageVersion)).

From 7f76df0b246c64fac0eeb115642c8cb6eb676f36 Mon Sep 17 00:00:00 2001
From: Michael Kriese <michael.kriese@visualon.de>
Date: Wed, 24 Jan 2024 01:45:20 +0100
Subject: [PATCH 6/6] fix: use xmlResponse

---
 modules/context/base.go             | 11 -----------
 routers/api/packages/nuget/nuget.go |  2 +-
 2 files changed, 1 insertion(+), 12 deletions(-)

diff --git a/modules/context/base.go b/modules/context/base.go
index 5b1cb14a82..8df1dde866 100644
--- a/modules/context/base.go
+++ b/modules/context/base.go
@@ -5,7 +5,6 @@ package context
 
 import (
 	"context"
-	"encoding/xml"
 	"fmt"
 	"io"
 	"net/http"
@@ -137,16 +136,6 @@ func (b *Base) JSON(status int, content any) {
 	}
 }
 
-// XML render content as XML
-func (b *Base) XML(status int, content any) {
-	b.Resp.Header().Set("Content-Type", "application/xml;charset=utf-8")
-	b.Resp.WriteHeader(status)
-	b.Resp.Write([]byte(xml.Header))
-	if err := xml.NewEncoder(b.Resp).Encode(content); err != nil {
-		log.Error("Render XML failed: %v", err)
-	}
-}
-
 // RemoteAddr returns the client machine ip address
 func (b *Base) RemoteAddr() string {
 	return b.Req.RemoteAddr
diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go
index bd495d5cc8..a63df2a1fc 100644
--- a/routers/api/packages/nuget/nuget.go
+++ b/routers/api/packages/nuget/nuget.go
@@ -412,7 +412,7 @@ func DownloadPackageFile(ctx *context.Context) {
 			Metadata: pd.Metadata.(*nuget_module.Metadata),
 		}
 
-		ctx.XML(http.StatusOK, nuget_module.GenerateNuspec(pkg))
+		xmlResponse(ctx, http.StatusOK, nuget_module.GenerateNuspec(pkg))
 	} else {
 		s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
 			ctx,