Skip to content

Commit 5a54d47

Browse files
committed
feat(nuget): support nuspec manifest download
1 parent 5dabc67 commit 5a54d47

File tree

6 files changed

+377
-14
lines changed

6 files changed

+377
-14
lines changed

modules/packages/nuget/metadata.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ const maxNuspecFileSize = 3 * 1024 * 1024
4848

4949
// Package represents a Nuget package
5050
type Package struct {
51-
PackageType PackageType
52-
ID string
53-
Version string
54-
Metadata *Metadata
51+
PackageType PackageType
52+
ID string
53+
Version string
54+
Metadata *Metadata
55+
NuspecContent *bytes.Buffer
5556
}
5657

5758
// Metadata represents the metadata of a Nuget package
@@ -130,8 +131,9 @@ func ParsePackageMetaData(r io.ReaderAt, size int64) (*Package, error) {
130131

131132
// ParseNuspecMetaData parses a Nuspec file to retrieve the metadata of a Nuget package
132133
func ParseNuspecMetaData(r io.Reader) (*Package, error) {
134+
var nuspecBuf bytes.Buffer
133135
var p nuspecPackage
134-
if err := xml.NewDecoder(r).Decode(&p); err != nil {
136+
if err := xml.NewDecoder(io.TeeReader(r, &nuspecBuf)).Decode(&p); err != nil {
135137
return nil, err
136138
}
137139

@@ -182,10 +184,11 @@ func ParseNuspecMetaData(r io.Reader) (*Package, error) {
182184
}
183185
}
184186
return &Package{
185-
PackageType: packageType,
186-
ID: p.Metadata.ID,
187-
Version: toNormalizedVersion(v),
188-
Metadata: m,
187+
PackageType: packageType,
188+
ID: p.Metadata.ID,
189+
Version: toNormalizedVersion(v),
190+
Metadata: m,
191+
NuspecContent: &nuspecBuf,
189192
}, nil
190193
}
191194

routers/api/packages/nuget/nuget.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,8 @@ func EnumeratePackageVersionsV3(ctx *context.Context) {
388388
ctx.JSON(http.StatusOK, resp)
389389
}
390390

391-
// https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
391+
// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-manifest-nuspec
392+
// https://learn.microsoft.com/en-us/nuget/api/package-base-address-resource#download-package-content-nupkg
392393
func DownloadPackageFile(ctx *context.Context) {
393394
packageName := ctx.Params("id")
394395
packageVersion := ctx.Params("version")
@@ -431,7 +432,7 @@ func UploadPackage(ctx *context.Context) {
431432
return
432433
}
433434

434-
_, _, err := packages_service.CreatePackageAndAddFile(
435+
pv, _, err := packages_service.CreatePackageAndAddFile(
435436
ctx,
436437
&packages_service.PackageCreationInfo{
437438
PackageInfo: packages_service.PackageInfo{
@@ -465,6 +466,35 @@ func UploadPackage(ctx *context.Context) {
465466
return
466467
}
467468

469+
nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len())
470+
if err != nil {
471+
apiError(ctx, http.StatusInternalServerError, err)
472+
return
473+
}
474+
defer nuspecBuf.Close()
475+
476+
_, err = packages_service.AddFileToPackageVersionInternal(
477+
ctx,
478+
pv,
479+
&packages_service.PackageFileCreationInfo{
480+
PackageFileInfo: packages_service.PackageFileInfo{
481+
Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", np.ID)),
482+
},
483+
Creator: ctx.Doer,
484+
Data: nuspecBuf,
485+
IsLead: false,
486+
},
487+
)
488+
if err != nil {
489+
switch err {
490+
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
491+
apiError(ctx, http.StatusForbidden, err)
492+
default:
493+
apiError(ctx, http.StatusInternalServerError, err)
494+
}
495+
return
496+
}
497+
468498
ctx.Status(http.StatusCreated)
469499
}
470500

services/doctor/doctor.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"code.gitea.io/gitea/modules/git"
1515
"code.gitea.io/gitea/modules/log"
1616
"code.gitea.io/gitea/modules/setting"
17+
"code.gitea.io/gitea/modules/storage"
1718
)
1819

1920
// Check represents a Doctor check
@@ -25,6 +26,7 @@ type Check struct {
2526
AbortIfFailed bool
2627
SkipDatabaseInitialization bool
2728
Priority int
29+
InitStorage bool
2830
}
2931

3032
func initDBSkipLogger(ctx context.Context) error {
@@ -84,6 +86,7 @@ func RunChecks(ctx context.Context, colorize, autofix bool, checks []*Check) err
8486
logger := log.BaseLoggerToGeneralLogger(&doctorCheckLogger{colorize: colorize})
8587
loggerStep := log.BaseLoggerToGeneralLogger(&doctorCheckStepLogger{colorize: colorize})
8688
dbIsInit := false
89+
storageIsInit := false
8790
for i, check := range checks {
8891
if !dbIsInit && !check.SkipDatabaseInitialization {
8992
// Only open database after the most basic configuration check
@@ -94,6 +97,14 @@ func RunChecks(ctx context.Context, colorize, autofix bool, checks []*Check) err
9497
}
9598
dbIsInit = true
9699
}
100+
if !storageIsInit && check.InitStorage {
101+
if err := storage.Init(); err != nil {
102+
logger.Error("Error whilst initializing the storage: %v", err)
103+
logger.Error("Check if you are using the right config file. You can use a --config directive to specify one.")
104+
return nil
105+
}
106+
storageIsInit = true
107+
}
97108
logger.Info("\n[%d] %s", i+1, check.Title)
98109
if err := check.Run(ctx, loggerStep, autofix); err != nil {
99110
if check.AbortIfFailed {

services/doctor/packages_nuget.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright 2024 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package doctor
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"slices"
10+
"strings"
11+
12+
"code.gitea.io/gitea/models/db"
13+
"code.gitea.io/gitea/models/packages"
14+
"code.gitea.io/gitea/models/user"
15+
"code.gitea.io/gitea/modules/log"
16+
packages_module "code.gitea.io/gitea/modules/packages"
17+
nuget_module "code.gitea.io/gitea/modules/packages/nuget"
18+
packages_service "code.gitea.io/gitea/services/packages"
19+
)
20+
21+
func init() {
22+
Register(&Check{
23+
Title: "Extract Nuget Nuspec Files to content store",
24+
Name: "packages-nuget-nuspec",
25+
IsDefault: false,
26+
Run: PackagesNugetNuspecCheck,
27+
Priority: 15,
28+
InitStorage: true,
29+
})
30+
}
31+
32+
// getAllUsers returns a slice of all users and organizations found in DB.
33+
func getAllUsers(ctx context.Context) ([]*user.User, error) {
34+
users := make([]*user.User, 0)
35+
return users, db.GetEngine(ctx).OrderBy("id").Find(&users)
36+
}
37+
38+
func PackagesNugetNuspecCheck(ctx context.Context, logger log.Logger, autofix bool) error {
39+
users, err := getAllUsers(ctx)
40+
userMap := make(map[int64]*user.User, len(users))
41+
42+
for _, u := range users {
43+
userMap[u.ID] = u
44+
}
45+
46+
if err != nil {
47+
logger.Error("Failed to get users: %v", err)
48+
return err
49+
}
50+
51+
logger.Info("Found %d users", len(users))
52+
53+
fixed := 0
54+
errors := 0
55+
56+
for _, user := range users {
57+
pkgs, err := packages.GetPackagesByType(ctx, user.ID, packages.TypeNuGet)
58+
if err != nil {
59+
logger.Error("Failed to get NuGet packages for owner %s: %v", user.Name, err)
60+
continue
61+
}
62+
63+
logger.Info("Found %d NuGet packages for owner %s", len(pkgs), user.Name)
64+
65+
for _, pkg := range pkgs {
66+
pvs, _, err := packages.SearchVersions(ctx, &packages.PackageSearchOptions{
67+
Type: packages.TypeNuGet,
68+
PackageID: pkg.ID,
69+
})
70+
if err != nil {
71+
// Should never happen
72+
logger.Error("Failed to search for versions for package %s: %v", pkg.Name, err)
73+
continue
74+
}
75+
76+
logger.Info("Found %d versions for package %s", pkg.Name, user.Name)
77+
78+
for _, pv := range pvs {
79+
80+
pfs, err := packages.GetFilesByVersionID(ctx, pv.ID)
81+
if err != nil {
82+
logger.Error("Failed to get files for package version %s %s: %v", pkg.Name, pv.Version, err)
83+
errors++
84+
continue
85+
}
86+
87+
if slices.IndexFunc(pfs, func(pf *packages.PackageFile) bool { return strings.HasSuffix(pf.LowerName, ".nuspec") }) >= 0 {
88+
logger.Debug("Nuspec file already exists for %s %s", pkg.Name, pv.Version)
89+
continue
90+
}
91+
92+
nuspecIdx := slices.IndexFunc(pfs, func(pf *packages.PackageFile) bool { return pf.IsLead })
93+
94+
if nuspecIdx < 0 {
95+
logger.Error("Missing nupkg file for %s %s", pkg.Name, pv.Version)
96+
errors++
97+
continue
98+
}
99+
100+
pf := pfs[nuspecIdx]
101+
102+
creator, ok := userMap[pv.CreatorID]
103+
if !ok {
104+
logger.Warn("Failed to find creator for %s %s", pkg.Name, pv.Version)
105+
creator = user
106+
}
107+
108+
logger.Info("Missing nuspec file found for %s %s", pkg.Name, pv.Version)
109+
fixed++
110+
111+
if autofix {
112+
s, _, _, err := packages_service.GetPackageFileStream(ctx, pf)
113+
if err != nil {
114+
logger.Error("Failed to get file stream for %s %s: %v", pkg.Name, pv.Version, err)
115+
errors++
116+
continue
117+
}
118+
defer s.Close()
119+
120+
buf, err := packages_module.CreateHashedBufferFromReader(s)
121+
if err != nil {
122+
logger.Error("Failed to create hashed buffer for nupkg from reader for %s %s: %v", pkg.Name, pv.Version, err)
123+
errors++
124+
continue
125+
}
126+
defer buf.Close()
127+
128+
np, err := nuget_module.ParsePackageMetaData(buf, buf.Size())
129+
if err != nil {
130+
logger.Error("Failed to parse package metadata for %s %s: %v", pkg.Name, pv.Version, err)
131+
errors++
132+
continue
133+
}
134+
135+
nuspecBuf, err := packages_module.CreateHashedBufferFromReaderWithSize(np.NuspecContent, np.NuspecContent.Len())
136+
if err != nil {
137+
logger.Error("Failed to create hashed buffer for nuspec from reader for %s %s: %v", pkg.Name, pv.Version, err)
138+
errors++
139+
continue
140+
}
141+
defer nuspecBuf.Close()
142+
143+
_, err = packages_service.AddFileToPackageVersionInternal(
144+
ctx,
145+
pv,
146+
&packages_service.PackageFileCreationInfo{
147+
PackageFileInfo: packages_service.PackageFileInfo{
148+
Filename: fmt.Sprintf("%s.nuspec", pkg.LowerName),
149+
},
150+
Creator: creator,
151+
Data: nuspecBuf,
152+
IsLead: false,
153+
},
154+
)
155+
if err != nil {
156+
logger.Error("Failed to add nuspec file for %s %s: %v", pkg.Name, pv.Version, err)
157+
errors++
158+
}
159+
}
160+
}
161+
}
162+
}
163+
164+
if fixed > 0 {
165+
logger.Info("Fixed %d NuGet packages by extracting nuspec files", fixed)
166+
}
167+
if errors > 0 {
168+
logger.Info("Failed to fix %d nuspec files", errors)
169+
}
170+
171+
return nil
172+
}

tests/integration/api_packages_nuget_test.go

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ func TestPackageNuGet(t *testing.T) {
112112
return &buf
113113
}
114114

115+
nuspec := `<?xml version="1.0" encoding="utf-8"?>
116+
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
117+
<metadata>
118+
<id>` + packageName + `</id>
119+
<version>` + packageVersion + `</version>
120+
<authors>` + packageAuthors + `</authors>
121+
<description>` + packageDescription + `</description>
122+
<dependencies>
123+
<group targetFramework=".NETStandard2.0">
124+
<dependency id="Microsoft.CSharp" version="4.5.0" />
125+
</group>
126+
</dependencies>
127+
</metadata>
128+
</package>`
115129
content, _ := io.ReadAll(createPackage(packageName, packageVersion))
116130

117131
url := fmt.Sprintf("/api/packages/%s/nuget", user.Name)
@@ -224,7 +238,7 @@ func TestPackageNuGet(t *testing.T) {
224238

225239
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet)
226240
assert.NoError(t, err)
227-
assert.Len(t, pvs, 1)
241+
assert.Len(t, pvs, 1, "Should have one version")
228242

229243
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
230244
assert.NoError(t, err)
@@ -235,7 +249,7 @@ func TestPackageNuGet(t *testing.T) {
235249

236250
pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
237251
assert.NoError(t, err)
238-
assert.Len(t, pfs, 1)
252+
assert.Len(t, pfs, 2, "Should have 2 files: nuget and nuspec")
239253
assert.Equal(t, fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion), pfs[0].Name)
240254
assert.True(t, pfs[0].IsLead)
241255

@@ -302,16 +316,27 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
302316

303317
pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
304318
assert.NoError(t, err)
305-
assert.Len(t, pfs, 3)
319+
assert.Len(t, pfs, 4, "Should have 4 files: nupkg, snupkg, nuspec and pdb")
306320
for _, pf := range pfs {
307321
switch pf.Name {
308322
case fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion):
323+
assert.True(t, pf.IsLead)
324+
325+
pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
326+
assert.NoError(t, err)
327+
assert.Equal(t, int64(414), pb.Size)
309328
case fmt.Sprintf("%s.%s.snupkg", packageName, packageVersion):
310329
assert.False(t, pf.IsLead)
311330

312331
pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
313332
assert.NoError(t, err)
314333
assert.Equal(t, int64(616), pb.Size)
334+
case fmt.Sprintf("%s.nuspec", packageName):
335+
assert.False(t, pf.IsLead)
336+
337+
pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
338+
assert.NoError(t, err)
339+
assert.Equal(t, int64(453), pb.Size)
315340
case symbolFilename:
316341
assert.False(t, pf.IsLead)
317342

@@ -353,6 +378,12 @@ AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
353378

354379
assert.Equal(t, content, resp.Body.Bytes())
355380

381+
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.nuspec", url, packageName, packageVersion, packageName)).
382+
AddBasicAuth(user.Name)
383+
resp = MakeRequest(t, req, http.StatusOK)
384+
385+
assert.Equal(t, nuspec, resp.Body.String())
386+
356387
checkDownloadCount(1)
357388

358389
req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.snupkg", url, packageName, packageVersion, packageName, packageVersion)).

0 commit comments

Comments
 (0)