diff options
Diffstat (limited to 'routers/api')
77 files changed, 1902 insertions, 1086 deletions
diff --git a/routers/api/actions/artifacts_utils.go b/routers/api/actions/artifacts_utils.go index 77ce765098..35868c290e 100644 --- a/routers/api/actions/artifacts_utils.go +++ b/routers/api/actions/artifacts_utils.go @@ -43,7 +43,7 @@ func validateRunID(ctx *ArtifactContext) (*actions.ActionTask, int64, bool) { return task, runID, true } -func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam +func validateRunIDV4(ctx *ArtifactContext, rawRunID string) (*actions.ActionTask, int64, bool) { //nolint:unparam // ActionTask is never used task := ctx.ActionTask runID, err := strconv.ParseInt(rawRunID, 10, 64) if err != nil || task.Job.RunID != runID { diff --git a/routers/api/packages/alpine/alpine.go b/routers/api/packages/alpine/alpine.go index f35cff3df2..ba4a4f23ce 100644 --- a/routers/api/packages/alpine/alpine.go +++ b/routers/api/packages/alpine/alpine.go @@ -68,7 +68,7 @@ func GetRepositoryFile(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion( ctx, pv, &packages_service.PackageFileInfo{ @@ -216,7 +216,7 @@ func DownloadPackageFile(ctx *context.Context) { } } - s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0]) if err != nil { if errors.Is(err, util.ErrNotExist) { apiError(ctx, http.StatusNotFound, err) diff --git a/routers/api/packages/api.go b/routers/api/packages/api.go index ae4ea7ea87..878e0f9945 100644 --- a/routers/api/packages/api.go +++ b/routers/api/packages/api.go @@ -5,8 +5,6 @@ package packages import ( "net/http" - "regexp" - "strings" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/perm" @@ -282,42 +280,10 @@ func CommonRoutes() *web.Router { }) }) }, reqPackageAccess(perm.AccessModeRead)) - r.Group("/conda", func() { - var ( - downloadPattern = regexp.MustCompile(`\A(.+/)?(.+)/((?:[^/]+(?:\.tar\.bz2|\.conda))|(?:current_)?repodata\.json(?:\.bz2)?)\z`) - uploadPattern = regexp.MustCompile(`\A(.+/)?([^/]+(?:\.tar\.bz2|\.conda))\z`) - ) - - r.Get("/*", func(ctx *context.Context) { - m := downloadPattern.FindStringSubmatch(ctx.PathParam("*")) - if len(m) == 0 { - ctx.Status(http.StatusNotFound) - return - } - - ctx.SetPathParam("channel", strings.TrimSuffix(m[1], "/")) - ctx.SetPathParam("architecture", m[2]) - ctx.SetPathParam("filename", m[3]) - - switch m[3] { - case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2": - conda.EnumeratePackages(ctx) - default: - conda.DownloadPackageFile(ctx) - } - }) - r.Put("/*", reqPackageAccess(perm.AccessModeWrite), func(ctx *context.Context) { - m := uploadPattern.FindStringSubmatch(ctx.PathParam("*")) - if len(m) == 0 { - ctx.Status(http.StatusNotFound) - return - } - - ctx.SetPathParam("channel", strings.TrimSuffix(m[1], "/")) - ctx.SetPathParam("filename", m[2]) - - conda.UploadPackageFile(ctx) - }) + r.PathGroup("/conda/*", func(g *web.RouterPathGroup) { + g.MatchPath("GET", "/<architecture>/<filename>", conda.ListOrGetPackages) + g.MatchPath("GET", "/<channel:*>/<architecture>/<filename>", conda.ListOrGetPackages) + g.MatchPath("PUT", "/<channel:*>/<filename>", reqPackageAccess(perm.AccessModeWrite), conda.UploadPackageFile) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/cran", func() { r.Group("/src", func() { @@ -358,60 +324,15 @@ func CommonRoutes() *web.Router { }, reqPackageAccess(perm.AccessModeRead)) r.Group("/go", func() { r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), goproxy.UploadPackage) - r.Get("/sumdb/sum.golang.org/supported", func(ctx *context.Context) { - ctx.Status(http.StatusNotFound) - }) + r.Get("/sumdb/sum.golang.org/supported", http.NotFound) - // Manual mapping of routes because the package name contains slashes which chi does not support // https://go.dev/ref/mod#goproxy-protocol - r.Get("/*", func(ctx *context.Context) { - path := ctx.PathParam("*") - - if strings.HasSuffix(path, "/@latest") { - ctx.SetPathParam("name", path[:len(path)-len("/@latest")]) - ctx.SetPathParam("version", "latest") - - goproxy.PackageVersionMetadata(ctx) - return - } - - parts := strings.SplitN(path, "/@v/", 2) - if len(parts) != 2 { - ctx.Status(http.StatusNotFound) - return - } - - ctx.SetPathParam("name", parts[0]) - - // <package/name>/@v/list - if parts[1] == "list" { - goproxy.EnumeratePackageVersions(ctx) - return - } - - // <package/name>/@v/<version>.zip - if strings.HasSuffix(parts[1], ".zip") { - ctx.SetPathParam("version", parts[1][:len(parts[1])-len(".zip")]) - - goproxy.DownloadPackageFile(ctx) - return - } - // <package/name>/@v/<version>.info - if strings.HasSuffix(parts[1], ".info") { - ctx.SetPathParam("version", parts[1][:len(parts[1])-len(".info")]) - - goproxy.PackageVersionMetadata(ctx) - return - } - // <package/name>/@v/<version>.mod - if strings.HasSuffix(parts[1], ".mod") { - ctx.SetPathParam("version", parts[1][:len(parts[1])-len(".mod")]) - - goproxy.PackageVersionGoModContent(ctx) - return - } - - ctx.Status(http.StatusNotFound) + r.PathGroup("/*", func(g *web.RouterPathGroup) { + g.MatchPath("GET", "/<name:*>/@<version:latest>", goproxy.PackageVersionMetadata) + g.MatchPath("GET", "/<name:*>/@v/list", goproxy.EnumeratePackageVersions) + g.MatchPath("GET", "/<name:*>/@v/<version>.zip", goproxy.DownloadPackageFile) + g.MatchPath("GET", "/<name:*>/@v/<version>.info", goproxy.PackageVersionMetadata) + g.MatchPath("GET", "/<name:*>/@v/<version>.mod", goproxy.PackageVersionGoModContent) }) }, reqPackageAccess(perm.AccessModeRead)) r.Group("/generic", func() { @@ -532,82 +453,26 @@ func CommonRoutes() *web.Router { }) }) }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/pypi", func() { r.Post("/", reqPackageAccess(perm.AccessModeWrite), pypi.UploadPackageFile) r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile) r.Get("/simple/{id}", pypi.PackageMetadata) }, reqPackageAccess(perm.AccessModeRead)) - r.Group("/rpm", func() { - r.Group("/repository.key", func() { - r.Head("", rpm.GetRepositoryKey) - r.Get("", rpm.GetRepositoryKey) - }) - - var ( - repoPattern = regexp.MustCompile(`\A(.*?)\.repo\z`) - uploadPattern = regexp.MustCompile(`\A(.*?)/upload\z`) - filePattern = regexp.MustCompile(`\A(.*?)/package/([^/]+)/([^/]+)/([^/]+)(?:/([^/]+\.rpm)|)\z`) - repoFilePattern = regexp.MustCompile(`\A(.*?)/repodata/([^/]+)\z`) - ) - - r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) { - path := ctx.PathParam("*") - isHead := ctx.Req.Method == http.MethodHead - isGetHead := ctx.Req.Method == http.MethodHead || ctx.Req.Method == http.MethodGet - isPut := ctx.Req.Method == http.MethodPut - isDelete := ctx.Req.Method == http.MethodDelete - - m := repoPattern.FindStringSubmatch(path) - if len(m) == 2 && isGetHead { - ctx.SetPathParam("group", strings.Trim(m[1], "/")) - rpm.GetRepositoryConfig(ctx) - return - } - m = repoFilePattern.FindStringSubmatch(path) - if len(m) == 3 && isGetHead { - ctx.SetPathParam("group", strings.Trim(m[1], "/")) - ctx.SetPathParam("filename", m[2]) - if isHead { - rpm.CheckRepositoryFileExistence(ctx) - } else { - rpm.GetRepositoryFile(ctx) - } - return - } - - m = uploadPattern.FindStringSubmatch(path) - if len(m) == 2 && isPut { - reqPackageAccess(perm.AccessModeWrite)(ctx) - if ctx.Written() { - return - } - ctx.SetPathParam("group", strings.Trim(m[1], "/")) - rpm.UploadPackageFile(ctx) - return - } - - m = filePattern.FindStringSubmatch(path) - if len(m) == 6 && (isGetHead || isDelete) { - ctx.SetPathParam("group", strings.Trim(m[1], "/")) - ctx.SetPathParam("name", m[2]) - ctx.SetPathParam("version", m[3]) - ctx.SetPathParam("architecture", m[4]) - if isGetHead { - rpm.DownloadPackageFile(ctx) - } else { - reqPackageAccess(perm.AccessModeWrite)(ctx) - if ctx.Written() { - return - } - rpm.DeletePackageFile(ctx) - } - return - } - - ctx.Status(http.StatusNotFound) - }) + r.Methods("HEAD,GET", "/rpm.repo", reqPackageAccess(perm.AccessModeRead), rpm.GetRepositoryConfig) + r.PathGroup("/rpm/*", func(g *web.RouterPathGroup) { + g.MatchPath("HEAD,GET", "/repository.key", rpm.GetRepositoryKey) + g.MatchPath("HEAD,GET", "/<group:*>.repo", rpm.GetRepositoryConfig) + g.MatchPath("HEAD", "/<group:*>/repodata/<filename>", rpm.CheckRepositoryFileExistence) + g.MatchPath("GET", "/<group:*>/repodata/<filename>", rpm.GetRepositoryFile) + g.MatchPath("PUT", "/<group:*>/upload", reqPackageAccess(perm.AccessModeWrite), rpm.UploadPackageFile) + // this URL pattern is only used internally in the RPM index, it is generated by us, the filename part is not really used (can be anything) + g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>", rpm.DownloadPackageFile) + g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>/<filename>", rpm.DownloadPackageFile) + g.MatchPath("DELETE", "/<group:*>/package/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile) }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/rubygems", func() { r.Get("/specs.4.8.gz", rubygems.EnumeratePackages) r.Get("/latest_specs.4.8.gz", rubygems.EnumeratePackagesLatest) @@ -621,6 +486,7 @@ func CommonRoutes() *web.Router { r.Delete("/yank", rubygems.DeletePackage) }, reqPackageAccess(perm.AccessModeWrite)) }, reqPackageAccess(perm.AccessModeRead)) + r.Group("/swift", func() { r.Group("", func() { // Needs to be unauthenticated. r.Post("", swift.CheckAuthenticate) @@ -632,31 +498,12 @@ func CommonRoutes() *web.Router { r.Get("", swift.EnumeratePackageVersions) r.Get(".json", swift.EnumeratePackageVersions) }, swift.CheckAcceptMediaType(swift.AcceptJSON)) - r.Group("/{version}", func() { - r.Get("/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest) - r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile) - r.Get("", func(ctx *context.Context) { - // Can't use normal routes here: https://github.com/go-chi/chi/issues/781 - - version := ctx.PathParam("version") - if strings.HasSuffix(version, ".zip") { - swift.CheckAcceptMediaType(swift.AcceptZip)(ctx) - if ctx.Written() { - return - } - ctx.SetPathParam("version", version[:len(version)-4]) - swift.DownloadPackageFile(ctx) - } else { - swift.CheckAcceptMediaType(swift.AcceptJSON)(ctx) - if ctx.Written() { - return - } - if strings.HasSuffix(version, ".json") { - ctx.SetPathParam("version", version[:len(version)-5]) - } - swift.PackageVersionMetadata(ctx) - } - }) + r.PathGroup("/*", func(g *web.RouterPathGroup) { + g.MatchPath("GET", "/<version>.json", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata) + g.MatchPath("GET", "/<version>.zip", swift.CheckAcceptMediaType(swift.AcceptZip), swift.DownloadPackageFile) + g.MatchPath("GET", "/<version>/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest) + g.MatchPath("GET", "/<version>", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata) + g.MatchPath("PUT", "/<version>", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile) }) }) r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers) @@ -693,6 +540,8 @@ func ContainerRoutes() *web.Router { &container.Auth{}, }) + // TODO: Content Discovery / References (not implemented yet) + r.Get("", container.ReqContainerAccess, container.DetermineSupport) r.Group("/token", func() { r.Get("", container.Authenticate) @@ -701,27 +550,22 @@ func ContainerRoutes() *web.Router { r.Get("/_catalog", container.ReqContainerAccess, container.GetRepositoryList) r.Group("/{username}", func() { r.PathGroup("/*", func(g *web.RouterPathGroup) { - g.MatchPath("POST", "/<image:*>/blobs/uploads", reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, container.InitiateUploadBlob) - g.MatchPath("GET", "/<image:*>/tags/list", container.VerifyImageName, container.GetTagList) - g.MatchPath("GET,PATCH,PUT,DELETE", `/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, func(ctx *context.Context) { - switch ctx.Req.Method { - case http.MethodGet: - container.GetUploadBlob(ctx) - case http.MethodPatch: - container.UploadBlob(ctx) - case http.MethodPut: - container.EndUploadBlob(ctx) - default: /* DELETE */ - container.CancelUploadBlob(ctx) - } - }) + g.MatchPath("POST", "/<image:*>/blobs/uploads", reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, container.PostBlobsUploads) + g.MatchPath("GET", "/<image:*>/tags/list", container.VerifyImageName, container.GetTagsList) + + patternBlobsUploadsUUID := g.PatternRegexp(`/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName) + g.MatchPattern("GET", patternBlobsUploadsUUID, container.GetBlobsUpload) + g.MatchPattern("PATCH", patternBlobsUploadsUUID, container.PatchBlobsUpload) + g.MatchPattern("PUT", patternBlobsUploadsUUID, container.PutBlobsUpload) + g.MatchPattern("DELETE", patternBlobsUploadsUUID, container.DeleteBlobsUpload) + g.MatchPath("HEAD", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.HeadBlob) g.MatchPath("GET", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.GetBlob) g.MatchPath("DELETE", `/<image:*>/blobs/<digest>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteBlob) g.MatchPath("HEAD", `/<image:*>/manifests/<reference>`, container.VerifyImageName, container.HeadManifest) g.MatchPath("GET", `/<image:*>/manifests/<reference>`, container.VerifyImageName, container.GetManifest) - g.MatchPath("PUT", `/<image:*>/manifests/<reference>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.UploadManifest) + g.MatchPath("PUT", `/<image:*>/manifests/<reference>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.PutManifest) g.MatchPath("DELETE", `/<image:*>/manifests/<reference>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteManifest) }) }, container.ReqContainerAccess, context.UserAssignmentWeb(), context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) diff --git a/routers/api/packages/arch/arch.go b/routers/api/packages/arch/arch.go index f5dc6c1d01..bf9cc3f1b8 100644 --- a/routers/api/packages/arch/arch.go +++ b/routers/api/packages/arch/arch.go @@ -239,7 +239,7 @@ func GetPackageOrRepositoryFile(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0]) if err != nil { if errors.Is(err, util.ErrNotExist) { apiError(ctx, http.StatusNotFound, err) diff --git a/routers/api/packages/cargo/cargo.go b/routers/api/packages/cargo/cargo.go index 710c614c6e..cfcf79244f 100644 --- a/routers/api/packages/cargo/cargo.go +++ b/routers/api/packages/cargo/cargo.go @@ -95,10 +95,7 @@ type SearchResultMeta struct { // https://doc.rust-lang.org/cargo/reference/registries.html#search func SearchPackages(ctx *context.Context) { - page := ctx.FormInt("page") - if page < 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) perPage := ctx.FormInt("per_page") paginator := db.ListOptions{ Page: page, @@ -168,7 +165,7 @@ func ListOwners(ctx *context.Context) { // DownloadPackageFile serves the content of a package func DownloadPackageFile(ctx *context.Context) { - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/chef/chef.go b/routers/api/packages/chef/chef.go index a0c8c5696c..1f11afe548 100644 --- a/routers/api/packages/chef/chef.go +++ b/routers/api/packages/chef/chef.go @@ -343,7 +343,7 @@ func DownloadPackage(ctx *context.Context) { pf := pd.Files[0].File - s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + s, u, _, err := packages_service.OpenFileForDownload(ctx, pf) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/packages/composer/composer.go b/routers/api/packages/composer/composer.go index c6c14e5cf4..9daf0ffeff 100644 --- a/routers/api/packages/composer/composer.go +++ b/routers/api/packages/composer/composer.go @@ -53,10 +53,7 @@ func ServiceIndex(ctx *context.Context) { // SearchPackages searches packages, only "q" is supported // https://packagist.org/apidoc#search-packages func SearchPackages(ctx *context.Context) { - page := ctx.FormInt("page") - if page < 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) perPage := ctx.FormInt("per_page") paginator := db.ListOptions{ Page: page, @@ -163,7 +160,7 @@ func PackageMetadata(ctx *context.Context) { // DownloadPackageFile serves the content of a package func DownloadPackageFile(ctx *context.Context) { - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/conan/conan.go b/routers/api/packages/conan/conan.go index 8019eee9f7..fe70e02cd6 100644 --- a/routers/api/packages/conan/conan.go +++ b/routers/api/packages/conan/conan.go @@ -480,7 +480,7 @@ func downloadFile(ctx *context.Context, fileFilter container.Set[string], fileKe return } - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/conda/conda.go b/routers/api/packages/conda/conda.go index 7a46681235..cfe069d6db 100644 --- a/routers/api/packages/conda/conda.go +++ b/routers/api/packages/conda/conda.go @@ -36,6 +36,24 @@ func apiError(ctx *context.Context, status int, obj any) { }) } +func isCondaPackageFileName(filename string) bool { + return strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".conda") +} + +func ListOrGetPackages(ctx *context.Context) { + filename := ctx.PathParam("filename") + switch filename { + case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2": + EnumeratePackages(ctx) + return + } + if isCondaPackageFileName(filename) { + DownloadPackageFile(ctx) + return + } + ctx.NotFound(nil) +} + func EnumeratePackages(ctx *context.Context) { type Info struct { Subdir string `json:"subdir"` @@ -174,6 +192,12 @@ func EnumeratePackages(ctx *context.Context) { } func UploadPackageFile(ctx *context.Context) { + filename := ctx.PathParam("filename") + if !isCondaPackageFileName(filename) { + apiError(ctx, http.StatusBadRequest, nil) + return + } + upload, needToClose, err := ctx.UploadStream() if err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -191,7 +215,7 @@ func UploadPackageFile(ctx *context.Context) { defer buf.Close() var pck *conda_module.Package - if strings.HasSuffix(strings.ToLower(ctx.PathParam("filename")), ".tar.bz2") { + if strings.HasSuffix(filename, ".tar.bz2") { pck, err = conda_module.ParsePackageBZ2(buf) } else { pck, err = conda_module.ParsePackageConda(buf, buf.Size()) @@ -293,7 +317,7 @@ func DownloadPackageFile(ctx *context.Context) { pf := pfs[0] - s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + s, u, _, err := packages_service.OpenFileForDownload(ctx, pf) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/packages/container/auth.go b/routers/api/packages/container/auth.go index 1d8ae6af7d..1e1b87eb79 100644 --- a/routers/api/packages/container/auth.go +++ b/routers/api/packages/container/auth.go @@ -21,7 +21,7 @@ func (a *Auth) Name() string { } // Verify extracts the user from the Bearer token -// If it's an anonymous session a ghost user is returned +// If it's an anonymous session, a ghost user is returned func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { packageMeta, err := packages.ParseAuthorizationRequest(req) if err != nil { diff --git a/routers/api/packages/container/blob.go b/routers/api/packages/container/blob.go index 4a2320ab76..4b7bcee9d0 100644 --- a/routers/api/packages/container/blob.go +++ b/routers/api/packages/container/blob.go @@ -20,11 +20,13 @@ import ( container_module "code.gitea.io/gitea/modules/packages/container" "code.gitea.io/gitea/modules/util" packages_service "code.gitea.io/gitea/services/packages" + + "github.com/opencontainers/go-digest" ) // saveAsPackageBlob creates a package blob from an upload // The uploaded blob gets stored in a special upload version to link them to the package/image -func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo) (*packages_model.PackageBlob, error) { //nolint:unparam +func saveAsPackageBlob(ctx context.Context, hsr packages_module.HashedSizeReader, pci *packages_service.PackageCreationInfo) (*packages_model.PackageBlob, error) { //nolint:unparam // PackageBlob is never used pb := packages_service.NewPackageBlob(hsr) exists := false @@ -88,20 +90,18 @@ func mountBlob(ctx context.Context, pi *packages_service.PackageInfo, pb *packag }) } -func containerPkgName(piOwnerID int64, piName string) string { - return fmt.Sprintf("pkg_%d_container_%s", piOwnerID, strings.ToLower(piName)) +func containerGlobalLockKey(piOwnerID int64, piName, usage string) string { + return fmt.Sprintf("pkg_%d_container_%s_%s", piOwnerID, strings.ToLower(piName), usage) } func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) { - var uploadVersion *packages_model.PackageVersion - - releaser, err := globallock.Lock(ctx, containerPkgName(pi.Owner.ID, pi.Name)) + releaser, err := globallock.Lock(ctx, containerGlobalLockKey(pi.Owner.ID, pi.Name, "package")) if err != nil { return nil, err } defer releaser() - err = db.WithTx(ctx, func(ctx context.Context) error { + return db.WithTx2(ctx, func(ctx context.Context) (*packages_model.PackageVersion, error) { created := true p := &packages_model.Package{ OwnerID: pi.Owner.ID, @@ -113,7 +113,7 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI if p, err = packages_model.TryInsertPackage(ctx, p); err != nil { if !errors.Is(err, packages_model.ErrDuplicatePackage) { log.Error("Error inserting package: %v", err) - return err + return nil, err } created = false } @@ -121,31 +121,26 @@ func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageI if created { if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypePackage, p.ID, container_module.PropertyRepository, strings.ToLower(pi.Owner.LowerName+"/"+pi.Name)); err != nil { log.Error("Error setting package property: %v", err) - return err + return nil, err } } pv := &packages_model.PackageVersion{ PackageID: p.ID, CreatorID: pi.Owner.ID, - Version: container_model.UploadVersion, - LowerVersion: container_model.UploadVersion, + Version: container_module.UploadVersion, + LowerVersion: container_module.UploadVersion, IsInternal: true, MetadataJSON: "null", } if pv, err = packages_model.GetOrInsertVersion(ctx, pv); err != nil { if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) { log.Error("Error inserting package: %v", err) - return err + return nil, err } } - - uploadVersion = pv - - return nil + return pv, nil }) - - return uploadVersion, err } func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, pb *packages_model.PackageBlob) error { @@ -175,8 +170,8 @@ func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, p return nil } -func deleteBlob(ctx context.Context, ownerID int64, image, digest string) error { - releaser, err := globallock.Lock(ctx, containerPkgName(ownerID, image)) +func deleteBlob(ctx context.Context, ownerID int64, image string, digest digest.Digest) error { + releaser, err := globallock.Lock(ctx, containerGlobalLockKey(ownerID, image, "blob")) if err != nil { return err } @@ -186,7 +181,7 @@ func deleteBlob(ctx context.Context, ownerID int64, image, digest string) error pfds, err := container_model.GetContainerBlobs(ctx, &container_model.BlobSearchOptions{ OwnerID: ownerID, Image: image, - Digest: digest, + Digest: string(digest), }) if err != nil { return err diff --git a/routers/api/packages/container/container.go b/routers/api/packages/container/container.go index 6ef1655235..d532f698ad 100644 --- a/routers/api/packages/container/container.go +++ b/routers/api/packages/container/container.go @@ -13,6 +13,7 @@ import ( "regexp" "strconv" "strings" + "sync" auth_model "code.gitea.io/gitea/models/auth" packages_model "code.gitea.io/gitea/models/packages" @@ -21,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" container_module "code.gitea.io/gitea/modules/packages/container" "code.gitea.io/gitea/modules/setting" @@ -31,17 +33,21 @@ import ( packages_service "code.gitea.io/gitea/services/packages" container_service "code.gitea.io/gitea/services/packages/container" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" ) // maximum size of a container manifest // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests const maxManifestSize = 10 * 1024 * 1024 -var ( - imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`) - referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`) -) +var globalVars = sync.OnceValue(func() (ret struct { + imageNamePattern, referencePattern *regexp.Regexp +}, +) { + ret.imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`) + ret.referencePattern = regexp.MustCompile(`\A[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}\z`) + return ret +}) type containerHeaders struct { Status int @@ -50,7 +56,7 @@ type containerHeaders struct { Range string Location string ContentType string - ContentLength int64 + ContentLength optional.Option[int64] } // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#legacy-docker-support-http-headers @@ -64,8 +70,8 @@ func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) { if h.ContentType != "" { resp.Header().Set("Content-Type", h.ContentType) } - if h.ContentLength != 0 { - resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) + if h.ContentLength.Has() { + resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength.Value(), 10)) } if h.UploadUUID != "" { resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID) @@ -83,9 +89,7 @@ func jsonResponse(ctx *context.Context, status int, obj any) { Status: status, ContentType: "application/json", }) - if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil { - log.Error("JSON encode: %v", err) - } + _ = json.NewEncoder(ctx.Resp).Encode(obj) // ignore network errors } func apiError(ctx *context.Context, status int, err error) { @@ -133,7 +137,7 @@ func ReqContainerAccess(ctx *context.Context) { // VerifyImageName is a middleware which checks if the image name is allowed func VerifyImageName(ctx *context.Context) { - if !imageNamePattern.MatchString(ctx.PathParam("image")) { + if !globalVars().imageNamePattern.MatchString(ctx.PathParam("image")) { apiErrorDefined(ctx, errNameInvalid) } } @@ -215,7 +219,7 @@ func GetRepositoryList(ctx *context.Context) { if len(repositories) == n { v := url.Values{} if n > 0 { - v.Add("n", strconv.Itoa(n)) + v.Add("n", strconv.Itoa(n)) // FIXME: "n" can't be zero here, the logic is inconsistent with GetTagsList } v.Add("last", repositories[len(repositories)-1]) @@ -230,7 +234,7 @@ func GetRepositoryList(ctx *context.Context) { // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks -func InitiateUploadBlob(ctx *context.Context) { +func PostBlobsUploads(ctx *context.Context) { image := ctx.PathParam("image") mount := ctx.FormTrim("mount") @@ -312,14 +316,14 @@ func InitiateUploadBlob(ctx *context.Context) { setResponseHeaders(ctx.Resp, &containerHeaders{ Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID), - Range: "0-0", UploadUUID: upload.ID, Status: http.StatusAccepted, }) } -// https://docs.docker.com/registry/spec/api/#get-blob-upload -func GetUploadBlob(ctx *context.Context) { +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks +func GetBlobsUpload(ctx *context.Context) { + image := ctx.PathParam("image") uuid := ctx.PathParam("uuid") upload, err := packages_model.GetBlobUploadByID(ctx, uuid) @@ -332,15 +336,21 @@ func GetUploadBlob(ctx *context.Context) { return } - setResponseHeaders(ctx.Resp, &containerHeaders{ - Range: fmt.Sprintf("0-%d", upload.BytesReceived), + // FIXME: undefined behavior when the uploaded content is empty: https://github.com/opencontainers/distribution-spec/issues/578 + respHeaders := &containerHeaders{ + Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID), UploadUUID: upload.ID, Status: http.StatusNoContent, - }) + } + if upload.BytesReceived > 0 { + respHeaders.Range = fmt.Sprintf("0-%d", upload.BytesReceived-1) + } + setResponseHeaders(ctx.Resp, respHeaders) } +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks -func UploadBlob(ctx *context.Context) { +func PatchBlobsUpload(ctx *context.Context) { image := ctx.PathParam("image") uploader, err := container_service.NewBlobUploader(ctx, ctx.PathParam("uuid")) @@ -376,16 +386,19 @@ func UploadBlob(ctx *context.Context) { return } - setResponseHeaders(ctx.Resp, &containerHeaders{ + respHeaders := &containerHeaders{ Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, uploader.ID), - Range: fmt.Sprintf("0-%d", uploader.Size()-1), UploadUUID: uploader.ID, Status: http.StatusAccepted, - }) + } + if uploader.Size() > 0 { + respHeaders.Range = fmt.Sprintf("0-%d", uploader.Size()-1) + } + setResponseHeaders(ctx.Resp, respHeaders) } // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks -func EndUploadBlob(ctx *context.Context) { +func PutBlobsUpload(ctx *context.Context) { image := ctx.PathParam("image") digest := ctx.FormTrim("digest") @@ -403,12 +416,7 @@ func EndUploadBlob(ctx *context.Context) { } return } - doClose := true - defer func() { - if doClose { - uploader.Close() - } - }() + defer uploader.Close() if ctx.Req.Body != nil { if err := uploader.Append(ctx, ctx.Req.Body); err != nil { @@ -441,11 +449,10 @@ func EndUploadBlob(ctx *context.Context) { return } - if err := uploader.Close(); err != nil { - apiError(ctx, http.StatusInternalServerError, err) - return - } - doClose = false + // There was a strange bug: the "Close" fails with error "close .../tmp/package-upload/....: file already closed" + // AFAIK there should be no other "Close" call to the uploader between NewBlobUploader and this line. + // At least it's safe to call Close twice, so ignore the error. + _ = uploader.Close() if err := container_service.RemoveBlobUploadByID(ctx, uploader.ID); err != nil { apiError(ctx, http.StatusInternalServerError, err) @@ -460,7 +467,7 @@ func EndUploadBlob(ctx *context.Context) { } // https://docs.docker.com/registry/spec/api/#delete-blob-upload -func CancelUploadBlob(ctx *context.Context) { +func DeleteBlobsUpload(ctx *context.Context) { uuid := ctx.PathParam("uuid") _, err := packages_model.GetBlobUploadByID(ctx, uuid) @@ -484,16 +491,15 @@ func CancelUploadBlob(ctx *context.Context) { } func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) { - d := ctx.PathParam("digest") - - if digest.Digest(d).Validate() != nil { + d := digest.Digest(ctx.PathParam("digest")) + if d.Validate() != nil { return nil, container_model.ErrContainerBlobNotExist } return workaroundGetContainerBlob(ctx, &container_model.BlobSearchOptions{ OwnerID: ctx.Package.Owner.ID, Image: ctx.PathParam("image"), - Digest: d, + Digest: string(d), }) } @@ -511,7 +517,7 @@ func HeadBlob(ctx *context.Context) { setResponseHeaders(ctx.Resp, &containerHeaders{ ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest), - ContentLength: blob.Blob.Size, + ContentLength: optional.Some(blob.Blob.Size), Status: http.StatusOK, }) } @@ -533,9 +539,8 @@ func GetBlob(ctx *context.Context) { // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-blobs func DeleteBlob(ctx *context.Context) { - d := ctx.PathParam("digest") - - if digest.Digest(d).Validate() != nil { + d := digest.Digest(ctx.PathParam("digest")) + if d.Validate() != nil { apiErrorDefined(ctx, errBlobUnknown) return } @@ -551,7 +556,7 @@ func DeleteBlob(ctx *context.Context) { } // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests -func UploadManifest(ctx *context.Context) { +func PutManifest(ctx *context.Context) { reference := ctx.PathParam("reference") mci := &manifestCreationInfo{ @@ -563,7 +568,7 @@ func UploadManifest(ctx *context.Context) { IsTagged: digest.Digest(reference).Validate() != nil, } - if mci.IsTagged && !referencePattern.MatchString(reference) { + if mci.IsTagged && !globalVars().referencePattern.MatchString(reference) { apiErrorDefined(ctx, errManifestInvalid.WithMessage("Tag is invalid")) return } @@ -607,18 +612,18 @@ func UploadManifest(ctx *context.Context) { } func getBlobSearchOptionsFromContext(ctx *context.Context) (*container_model.BlobSearchOptions, error) { - reference := ctx.PathParam("reference") - opts := &container_model.BlobSearchOptions{ OwnerID: ctx.Package.Owner.ID, Image: ctx.PathParam("image"), IsManifest: true, } - if digest.Digest(reference).Validate() == nil { - opts.Digest = reference - } else if referencePattern.MatchString(reference) { + reference := ctx.PathParam("reference") + if d := digest.Digest(reference); d.Validate() == nil { + opts.Digest = string(d) + } else if globalVars().referencePattern.MatchString(reference) { opts.Tag = reference + opts.OnlyLead = true } else { return nil, container_model.ErrContainerBlobNotExist } @@ -650,7 +655,7 @@ func HeadManifest(ctx *context.Context) { setResponseHeaders(ctx.Resp, &containerHeaders{ ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest), ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType), - ContentLength: manifest.Blob.Size, + ContentLength: optional.Some(manifest.Blob.Size), Status: http.StatusOK, }) } @@ -705,7 +710,7 @@ func DeleteManifest(ctx *context.Context) { func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) { serveDirectReqParams := make(url.Values) serveDirectReqParams.Set("response-content-type", pfd.Properties.GetByName(container_module.PropertyMediaType)) - s, u, _, err := packages_service.GetPackageBlobStream(ctx, pfd.File, pfd.Blob, serveDirectReqParams) + s, u, _, err := packages_service.OpenBlobForDownload(ctx, pfd.File, pfd.Blob, serveDirectReqParams) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -714,14 +719,14 @@ func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) headers := &containerHeaders{ ContentDigest: pfd.Properties.GetByName(container_module.PropertyDigest), ContentType: pfd.Properties.GetByName(container_module.PropertyMediaType), - ContentLength: pfd.Blob.Size, + ContentLength: optional.Some(pfd.Blob.Size), Status: http.StatusOK, } if u != nil { headers.Status = http.StatusTemporaryRedirect headers.Location = u.String() - + headers.ContentLength = optional.None[int64]() // do not set Content-Length for redirect responses setResponseHeaders(ctx.Resp, headers) return } @@ -735,7 +740,7 @@ func serveBlob(ctx *context.Context, pfd *packages_model.PackageFileDescriptor) } // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery -func GetTagList(ctx *context.Context) { +func GetTagsList(ctx *context.Context) { image := ctx.PathParam("image") if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil { @@ -780,7 +785,8 @@ func GetTagList(ctx *context.Context) { }) } -// FIXME: Workaround to be removed in v1.20 +// FIXME: Workaround to be removed in v1.20. +// Update maybe we should never really remote it, as long as there is legacy data? // https://github.com/go-gitea/gitea/issues/19586 func workaroundGetContainerBlob(ctx *context.Context, opts *container_model.BlobSearchOptions) (*packages_model.PackageFileDescriptor, error) { blob, err := container_model.GetContainerBlob(ctx, opts) diff --git a/routers/api/packages/container/manifest.go b/routers/api/packages/container/manifest.go index 26faa7b024..de40215aa7 100644 --- a/routers/api/packages/container/manifest.go +++ b/routers/api/packages/container/manifest.go @@ -10,11 +10,13 @@ import ( "io" "os" "strings" + "time" "code.gitea.io/gitea/models/db" packages_model "code.gitea.io/gitea/models/packages" container_model "code.gitea.io/gitea/models/packages/container" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/globallock" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" packages_module "code.gitea.io/gitea/modules/packages" @@ -22,23 +24,12 @@ import ( "code.gitea.io/gitea/modules/util" notify_service "code.gitea.io/gitea/services/notify" packages_service "code.gitea.io/gitea/services/packages" + container_service "code.gitea.io/gitea/services/packages/container" - digest "github.com/opencontainers/go-digest" + "github.com/opencontainers/go-digest" oci "github.com/opencontainers/image-spec/specs-go/v1" ) -func isValidMediaType(mt string) bool { - return strings.HasPrefix(mt, "application/vnd.docker.") || strings.HasPrefix(mt, "application/vnd.oci.") -} - -func isImageManifestMediaType(mt string) bool { - return strings.EqualFold(mt, oci.MediaTypeImageManifest) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.v2+json") -} - -func isImageIndexMediaType(mt string) bool { - return strings.EqualFold(mt, oci.MediaTypeImageIndex) || strings.EqualFold(mt, "application/vnd.docker.distribution.manifest.list.v2+json") -} - // manifestCreationInfo describes a manifest to create type manifestCreationInfo struct { MediaType string @@ -55,71 +46,71 @@ func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packag if err := json.NewDecoder(buf).Decode(&index); err != nil { return "", err } - if index.SchemaVersion != 2 { return "", errUnsupported.WithMessage("Schema version is not supported") } - if _, err := buf.Seek(0, io.SeekStart); err != nil { return "", err } - if !isValidMediaType(mci.MediaType) { + if !container_module.IsMediaTypeValid(mci.MediaType) { mci.MediaType = index.MediaType - if !isValidMediaType(mci.MediaType) { + if !container_module.IsMediaTypeValid(mci.MediaType) { return "", errManifestInvalid.WithMessage("MediaType not recognized") } } - if isImageManifestMediaType(mci.MediaType) { - return processImageManifest(ctx, mci, buf) - } else if isImageIndexMediaType(mci.MediaType) { - return processImageManifestIndex(ctx, mci, buf) + // .../container/manifest.go:453:createManifestBlob() [E] Error inserting package blob: Error 1062 (23000): Duplicate entry '..........' for key 'package_blob.UQE_package_blob_md5' + releaser, err := globallock.Lock(ctx, containerGlobalLockKey(mci.Owner.ID, mci.Image, "manifest")) + if err != nil { + return "", err + } + defer releaser() + + if container_module.IsMediaTypeImageManifest(mci.MediaType) { + return processOciImageManifest(ctx, mci, buf) + } else if container_module.IsMediaTypeImageIndex(mci.MediaType) { + return processOciImageIndex(ctx, mci, buf) } return "", errManifestInvalid } -func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { - manifestDigest := "" - - err := func() error { - var manifest oci.Manifest - if err := json.NewDecoder(buf).Decode(&manifest); err != nil { - return err - } - - if _, err := buf.Seek(0, io.SeekStart); err != nil { - return err - } - - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - configDescriptor, err := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{ - OwnerID: mci.Owner.ID, - Image: mci.Image, - Digest: string(manifest.Config.Digest), - }) - if err != nil { - return err - } +type processManifestTxRet struct { + pv *packages_model.PackageVersion + pb *packages_model.PackageBlob + created bool + digest string +} - configReader, err := packages_module.NewContentStore().Get(packages_module.BlobHash256Key(configDescriptor.Blob.HashSHA256)) - if err != nil { - return err +func handleCreateManifestResult(ctx context.Context, err error, mci *manifestCreationInfo, contentStore *packages_module.ContentStore, txRet *processManifestTxRet) (string, error) { + if err != nil && txRet.created && txRet.pb != nil { + if err := contentStore.Delete(packages_module.BlobHash256Key(txRet.pb.HashSHA256)); err != nil { + log.Error("Error deleting package blob from content store: %v", err) } - defer configReader.Close() + return "", err + } + pd, err := packages_model.GetPackageDescriptor(ctx, txRet.pv) + if err != nil { + log.Error("Error getting package descriptor: %v", err) // ignore this error + } else { + notify_service.PackageCreate(ctx, mci.Creator, pd) + } + return txRet.digest, nil +} - metadata, err := container_module.ParseImageConfig(manifest.Config.MediaType, configReader) - if err != nil { - return err - } +func processOciImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet error) { + manifest, configDescriptor, metadata, err := container_service.ParseManifestMetadata(ctx, buf, mci.Owner.ID, mci.Image) + if err != nil { + return "", err + } + if _, err = buf.Seek(0, io.SeekStart); err != nil { + return "", err + } + contentStore := packages_module.NewContentStore() + var txRet processManifestTxRet + err = db.WithTx(ctx, func(ctx context.Context) (err error) { blobReferences := make([]*blobReference, 0, 1+len(manifest.Layers)) - blobReferences = append(blobReferences, &blobReference{ Digest: manifest.Config.Digest, MediaType: manifest.Config.MediaType, @@ -150,78 +141,43 @@ func processImageManifest(ctx context.Context, mci *manifestCreationInfo, buf *p return err } - uploadVersion, err := packages_model.GetInternalVersionByNameAndVersion(ctx, mci.Owner.ID, packages_model.TypeContainer, mci.Image, container_model.UploadVersion) - if err != nil && err != packages_model.ErrPackageNotExist { + uploadVersion, err := packages_model.GetInternalVersionByNameAndVersion(ctx, mci.Owner.ID, packages_model.TypeContainer, mci.Image, container_module.UploadVersion) + if err != nil && !errors.Is(err, packages_model.ErrPackageNotExist) { return err } for _, ref := range blobReferences { - if err := createFileFromBlobReference(ctx, pv, uploadVersion, ref); err != nil { + if _, err = createFileFromBlobReference(ctx, pv, uploadVersion, ref); err != nil { return err } } + txRet.pv = pv + txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf) + return err + }) - pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf) - removeBlob := false - defer func() { - if removeBlob { - contentStore := packages_module.NewContentStore() - if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil { - log.Error("Error deleting package blob from content store: %v", err) - } - } - }() - if err != nil { - removeBlob = created - return err - } - - if err := committer.Commit(); err != nil { - removeBlob = created - return err - } - - if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil { - return err - } - - manifestDigest = digest + return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet) +} - return nil - }() - if err != nil { +func processOciImageIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (manifestDigest string, errRet error) { + var index oci.Index + if err := json.NewDecoder(buf).Decode(&index); err != nil { + return "", err + } + if _, err := buf.Seek(0, io.SeekStart); err != nil { return "", err } - return manifestDigest, nil -} - -func processImageManifestIndex(ctx context.Context, mci *manifestCreationInfo, buf *packages_module.HashedBuffer) (string, error) { - manifestDigest := "" - - err := func() error { - var index oci.Index - if err := json.NewDecoder(buf).Decode(&index); err != nil { - return err - } - - if _, err := buf.Seek(0, io.SeekStart); err != nil { - return err - } - - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - + contentStore := packages_module.NewContentStore() + var txRet processManifestTxRet + err := db.WithTx(ctx, func(ctx context.Context) (err error) { metadata := &container_module.Metadata{ Type: container_module.TypeOCI, Manifests: make([]*container_module.Manifest, 0, len(index.Manifests)), } for _, manifest := range index.Manifests { - if !isImageManifestMediaType(manifest.MediaType) { + if !container_module.IsMediaTypeImageManifest(manifest.MediaType) { return errManifestInvalid } @@ -265,50 +221,12 @@ func processImageManifestIndex(ctx context.Context, mci *manifestCreationInfo, b return err } - pb, created, digest, err := createManifestBlob(ctx, mci, pv, buf) - removeBlob := false - defer func() { - if removeBlob { - contentStore := packages_module.NewContentStore() - if err := contentStore.Delete(packages_module.BlobHash256Key(pb.HashSHA256)); err != nil { - log.Error("Error deleting package blob from content store: %v", err) - } - } - }() - if err != nil { - removeBlob = created - return err - } - - if err := committer.Commit(); err != nil { - removeBlob = created - return err - } - - if err := notifyPackageCreate(ctx, mci.Creator, pv); err != nil { - return err - } - - manifestDigest = digest - - return nil - }() - if err != nil { - return "", err - } - - return manifestDigest, nil -} - -func notifyPackageCreate(ctx context.Context, doer *user_model.User, pv *packages_model.PackageVersion) error { - pd, err := packages_model.GetPackageDescriptor(ctx, pv) - if err != nil { + txRet.pv = pv + txRet.pb, txRet.created, txRet.digest, err = createManifestBlob(ctx, contentStore, mci, pv, buf) return err - } - - notify_service.PackageCreate(ctx, doer, pd) + }) - return nil + return handleCreateManifestResult(ctx, err, mci, contentStore, &txRet) } func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, metadata *container_module.Metadata) (*packages_model.PackageVersion, error) { @@ -349,24 +267,31 @@ func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, met LowerVersion: strings.ToLower(mci.Reference), MetadataJSON: string(metadataJSON), } - var pv *packages_model.PackageVersion - if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil { + pv, err := packages_model.GetOrInsertVersion(ctx, _pv) + if err != nil { if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) { log.Error("Error inserting package: %v", err) return nil, err } - if err = packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil { - return nil, err - } - - // keep download count on overwrite - _pv.DownloadCount = pv.DownloadCount - - if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil { - if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) { - log.Error("Error inserting package: %v", err) - return nil, err + if container_module.IsMediaTypeImageIndex(mci.MediaType) { + if pv.CreatedUnix.AsTime().Before(time.Now().Add(-24 * time.Hour)) { + if err = packages_service.DeletePackageVersionAndReferences(ctx, pv); err != nil { + return nil, err + } + // keep download count on overwriting + _pv.DownloadCount = pv.DownloadCount + if pv, err = packages_model.GetOrInsertVersion(ctx, _pv); err != nil { + if !errors.Is(err, packages_model.ErrDuplicatePackageVersion) { + log.Error("Error inserting package: %v", err) + return nil, err + } + } + } else { + err = packages_model.UpdateVersion(ctx, &packages_model.PackageVersion{ID: pv.ID, MetadataJSON: _pv.MetadataJSON}) + if err != nil { + return nil, err + } } } } @@ -376,14 +301,20 @@ func createPackageAndVersion(ctx context.Context, mci *manifestCreationInfo, met } if mci.IsTagged { - if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged, ""); err != nil { - log.Error("Error setting package version property: %v", err) + if err = packages_model.InsertOrUpdateProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged, ""); err != nil { + return nil, err + } + } else { + if err = packages_model.DeletePropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestTagged); err != nil { return nil, err } } + + if err = packages_model.DeletePropertiesByName(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference); err != nil { + return nil, err + } for _, manifest := range metadata.Manifests { - if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference, manifest.Digest); err != nil { - log.Error("Error setting package version property: %v", err) + if _, err = packages_model.InsertProperty(ctx, packages_model.PropertyTypeVersion, pv.ID, container_module.PropertyManifestReference, manifest.Digest); err != nil { return nil, err } } @@ -400,9 +331,9 @@ type blobReference struct { IsLead bool } -func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *packages_model.PackageVersion, ref *blobReference) error { +func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *packages_model.PackageVersion, ref *blobReference) (*packages_model.PackageFile, error) { if ref.File.Blob.Size != ref.ExpectedSize { - return errSizeInvalid + return nil, errSizeInvalid } if ref.Name == "" { @@ -410,20 +341,21 @@ func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *package } pf := &packages_model.PackageFile{ - VersionID: pv.ID, - BlobID: ref.File.Blob.ID, - Name: ref.Name, - LowerName: ref.Name, - IsLead: ref.IsLead, + VersionID: pv.ID, + BlobID: ref.File.Blob.ID, + Name: ref.Name, + LowerName: ref.Name, + CompositeKey: string(ref.Digest), + IsLead: ref.IsLead, } var err error if pf, err = packages_model.TryInsertFile(ctx, pf); err != nil { if errors.Is(err, packages_model.ErrDuplicatePackageFile) { // Skip this blob because the manifest contains the same filesystem layer multiple times. - return nil + return pf, nil } log.Error("Error inserting package file: %v", err) - return err + return nil, err } props := map[string]string{ @@ -433,21 +365,21 @@ func createFileFromBlobReference(ctx context.Context, pv, uploadVersion *package for name, value := range props { if _, err := packages_model.InsertProperty(ctx, packages_model.PropertyTypeFile, pf.ID, name, value); err != nil { log.Error("Error setting package file property: %v", err) - return err + return nil, err } } - // Remove the file from the blob upload version + // Remove the ref file (old file) from the blob upload version if uploadVersion != nil && ref.File.File != nil && uploadVersion.ID == ref.File.File.VersionID { if err := packages_service.DeletePackageFile(ctx, ref.File.File); err != nil { - return err + return nil, err } } - return nil + return pf, nil } -func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (*packages_model.PackageBlob, bool, string, error) { +func createManifestBlob(ctx context.Context, contentStore *packages_module.ContentStore, mci *manifestCreationInfo, pv *packages_model.PackageVersion, buf *packages_module.HashedBuffer) (_ *packages_model.PackageBlob, created bool, manifestDigest string, _ error) { pb, exists, err := packages_model.GetOrInsertBlob(ctx, packages_service.NewPackageBlob(buf)) if err != nil { log.Error("Error inserting package blob: %v", err) @@ -456,29 +388,48 @@ func createManifestBlob(ctx context.Context, mci *manifestCreationInfo, pv *pack // FIXME: Workaround to be removed in v1.20 // https://github.com/go-gitea/gitea/issues/19586 if exists { - err = packages_module.NewContentStore().Has(packages_module.BlobHash256Key(pb.HashSHA256)) + err = contentStore.Has(packages_module.BlobHash256Key(pb.HashSHA256)) if err != nil && (errors.Is(err, util.ErrNotExist) || errors.Is(err, os.ErrNotExist)) { log.Debug("Package registry inconsistent: blob %s does not exist on file system", pb.HashSHA256) exists = false } } if !exists { - contentStore := packages_module.NewContentStore() if err := contentStore.Save(packages_module.BlobHash256Key(pb.HashSHA256), buf, buf.Size()); err != nil { log.Error("Error saving package blob in content store: %v", err) return nil, false, "", err } } - manifestDigest := digestFromHashSummer(buf) - err = createFileFromBlobReference(ctx, pv, nil, &blobReference{ + manifestDigest = digestFromHashSummer(buf) + pf, err := createFileFromBlobReference(ctx, pv, nil, &blobReference{ Digest: digest.Digest(manifestDigest), MediaType: mci.MediaType, - Name: container_model.ManifestFilename, + Name: container_module.ManifestFilename, File: &packages_model.PackageFileDescriptor{Blob: pb}, ExpectedSize: pb.Size, IsLead: true, }) + if err != nil { + return nil, false, "", err + } + oldManifestFiles, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ + OwnerID: mci.Owner.ID, + PackageType: packages_model.TypeContainer, + VersionID: pv.ID, + Query: container_module.ManifestFilename, + }) + if err != nil { + return nil, false, "", err + } + for _, oldManifestFile := range oldManifestFiles { + if oldManifestFile.ID != pf.ID && oldManifestFile.IsLead { + err = packages_model.UpdateFile(ctx, &packages_model.PackageFile{ID: oldManifestFile.ID, IsLead: false}, []string{"is_lead"}) + if err != nil { + return nil, false, "", err + } + } + } return pb, !exists, manifestDigest, err } diff --git a/routers/api/packages/cran/cran.go b/routers/api/packages/cran/cran.go index 8a20072cb6..732acd215f 100644 --- a/routers/api/packages/cran/cran.go +++ b/routers/api/packages/cran/cran.go @@ -250,7 +250,7 @@ func downloadPackageFile(ctx *context.Context, opts *cran_model.SearchOptions) { return } - s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + s, u, _, err := packages_service.OpenFileForDownload(ctx, pf) if err != nil { if errors.Is(err, util.ErrNotExist) { apiError(ctx, http.StatusNotFound, err) diff --git a/routers/api/packages/debian/debian.go b/routers/api/packages/debian/debian.go index fec34c91a6..346f71fa5d 100644 --- a/routers/api/packages/debian/debian.go +++ b/routers/api/packages/debian/debian.go @@ -59,7 +59,7 @@ func GetRepositoryFile(ctx *context.Context) { key += "|" + component + "|" + architecture } - s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion( ctx, pv, &packages_service.PackageFileInfo{ @@ -106,7 +106,7 @@ func GetRepositoryFileByHash(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0]) if err != nil { if errors.Is(err, util.ErrNotExist) { apiError(ctx, http.StatusNotFound, err) @@ -210,7 +210,7 @@ func DownloadPackageFile(ctx *context.Context) { name := ctx.PathParam("name") version := ctx.PathParam("version") - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/generic/generic.go b/routers/api/packages/generic/generic.go index 0b5daa7334..db7aeace50 100644 --- a/routers/api/packages/generic/generic.go +++ b/routers/api/packages/generic/generic.go @@ -31,7 +31,7 @@ func apiError(ctx *context.Context, status int, obj any) { // DownloadPackageFile serves the specific generic package. func DownloadPackageFile(ctx *context.Context) { - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/goproxy/goproxy.go b/routers/api/packages/goproxy/goproxy.go index bde29df739..89ec86bce9 100644 --- a/routers/api/packages/goproxy/goproxy.go +++ b/routers/api/packages/goproxy/goproxy.go @@ -106,7 +106,7 @@ func DownloadPackageFile(ctx *context.Context) { return } - s, u, _, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + s, u, _, err := packages_service.OpenFileForDownload(ctx, pfs[0]) if err != nil { if errors.Is(err, util.ErrNotExist) { apiError(ctx, http.StatusNotFound, err) diff --git a/routers/api/packages/helm/helm.go b/routers/api/packages/helm/helm.go index fb12daaa46..39c34f4da4 100644 --- a/routers/api/packages/helm/helm.go +++ b/routers/api/packages/helm/helm.go @@ -122,7 +122,7 @@ func DownloadPackageFile(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion( ctx, pvs[0], &packages_service.PackageFileInfo{ diff --git a/routers/api/packages/maven/maven.go b/routers/api/packages/maven/maven.go index 9089c2eccf..40a8ff8242 100644 --- a/routers/api/packages/maven/maven.go +++ b/routers/api/packages/maven/maven.go @@ -223,7 +223,7 @@ func servePackageFile(ctx *context.Context, params parameters, serveContent bool return } - s, u, _, err := packages_service.GetPackageBlobStream(ctx, pf, pb, nil) + s, u, _, err := packages_service.OpenBlobForDownload(ctx, pf, pb, nil) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/packages/npm/npm.go b/routers/api/packages/npm/npm.go index 6ec46bcb36..1f09816d32 100644 --- a/routers/api/packages/npm/npm.go +++ b/routers/api/packages/npm/npm.go @@ -85,7 +85,7 @@ func DownloadPackageFile(ctx *context.Context) { packageVersion := ctx.PathParam("version") filename := ctx.PathParam("filename") - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -132,7 +132,7 @@ func DownloadPackageFileByName(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion( ctx, pvs[0], &packages_service.PackageFileInfo{ diff --git a/routers/api/packages/nuget/api_v2.go b/routers/api/packages/nuget/api_v2.go index a726065ad0..801c60af13 100644 --- a/routers/api/packages/nuget/api_v2.go +++ b/routers/api/packages/nuget/api_v2.go @@ -246,21 +246,30 @@ type TypedValue[T any] struct { } type FeedEntryProperties struct { - Version string `xml:"d:Version"` - NormalizedVersion string `xml:"d:NormalizedVersion"` Authors string `xml:"d:Authors"` + Copyright string `xml:"d:Copyright,omitempty"` + Created TypedValue[time.Time] `xml:"d:Created"` Dependencies string `xml:"d:Dependencies"` Description string `xml:"d:Description"` - VersionDownloadCount TypedValue[int64] `xml:"d:VersionDownloadCount"` + DevelopmentDependency TypedValue[bool] `xml:"d:DevelopmentDependency"` DownloadCount TypedValue[int64] `xml:"d:DownloadCount"` - PackageSize TypedValue[int64] `xml:"d:PackageSize"` - Created TypedValue[time.Time] `xml:"d:Created"` + ID string `xml:"d:Id"` + IconURL string `xml:"d:IconUrl,omitempty"` + Language string `xml:"d:Language,omitempty"` LastUpdated TypedValue[time.Time] `xml:"d:LastUpdated"` - Published TypedValue[time.Time] `xml:"d:Published"` + LicenseURL string `xml:"d:LicenseUrl,omitempty"` + MinClientVersion string `xml:"d:MinClientVersion,omitempty"` + NormalizedVersion string `xml:"d:NormalizedVersion"` + Owners string `xml:"d:Owners,omitempty"` + PackageSize TypedValue[int64] `xml:"d:PackageSize"` ProjectURL string `xml:"d:ProjectUrl,omitempty"` + Published TypedValue[time.Time] `xml:"d:Published"` ReleaseNotes string `xml:"d:ReleaseNotes,omitempty"` RequireLicenseAcceptance TypedValue[bool] `xml:"d:RequireLicenseAcceptance"` - Title string `xml:"d:Title"` + Tags string `xml:"d:Tags,omitempty"` + Title string `xml:"d:Title,omitempty"` + Version string `xml:"d:Version"` + VersionDownloadCount TypedValue[int64] `xml:"d:VersionDownloadCount"` } type FeedEntry struct { @@ -353,21 +362,30 @@ func createEntry(l *linkBuilder, pd *packages_model.PackageDescriptor, withNames Author: metadata.Authors, Content: content, Properties: &FeedEntryProperties{ - Version: pd.Version.Version, - NormalizedVersion: pd.Version.Version, Authors: metadata.Authors, + Copyright: metadata.Copyright, + Created: createdValue, Dependencies: buildDependencyString(metadata), Description: metadata.Description, - VersionDownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount}, + DevelopmentDependency: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.DevelopmentDependency}, DownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount}, - PackageSize: TypedValue[int64]{Type: "Edm.Int64", Value: pd.CalculateBlobSize()}, - Created: createdValue, + ID: pd.Package.Name, + IconURL: metadata.IconURL, + Language: metadata.Language, LastUpdated: createdValue, - Published: createdValue, + LicenseURL: metadata.LicenseURL, + MinClientVersion: metadata.MinClientVersion, + NormalizedVersion: pd.Version.Version, + Owners: metadata.Owners, + PackageSize: TypedValue[int64]{Type: "Edm.Int64", Value: pd.CalculateBlobSize()}, ProjectURL: metadata.ProjectURL, + Published: createdValue, ReleaseNotes: metadata.ReleaseNotes, RequireLicenseAcceptance: TypedValue[bool]{Type: "Edm.Boolean", Value: metadata.RequireLicenseAcceptance}, - Title: pd.Package.Name, + Tags: metadata.Tags, + Title: metadata.Title, + Version: pd.Version.Version, + VersionDownloadCount: TypedValue[int64]{Type: "Edm.Int64", Value: pd.Version.DownloadCount}, }, } diff --git a/routers/api/packages/nuget/nuget.go b/routers/api/packages/nuget/nuget.go index fa5067a278..92d62d90b1 100644 --- a/routers/api/packages/nuget/nuget.go +++ b/routers/api/packages/nuget/nuget.go @@ -36,7 +36,7 @@ func apiError(ctx *context.Context, status int, obj any) { }) } -func xmlResponse(ctx *context.Context, status int, obj any) { //nolint:unparam +func xmlResponse(ctx *context.Context, status int, obj any) { //nolint:unparam // status is always StatusOK ctx.Resp.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") ctx.Resp.WriteHeader(status) if _, err := ctx.Resp.Write([]byte(xml.Header)); err != nil { @@ -405,7 +405,7 @@ func DownloadPackageFile(ctx *context.Context) { packageVersion := ctx.PathParam("version") filename := ctx.PathParam("filename") - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, @@ -669,7 +669,7 @@ func DownloadSymbolFile(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) + s, u, pf, err := packages_service.OpenFileForDownload(ctx, pfs[0]) if err != nil { if errors.Is(err, packages_model.ErrPackageNotExist) || errors.Is(err, packages_model.ErrPackageFileNotExist) { apiError(ctx, http.StatusNotFound, err) diff --git a/routers/api/packages/pub/pub.go b/routers/api/packages/pub/pub.go index e7b07aefd0..4bd36e94b6 100644 --- a/routers/api/packages/pub/pub.go +++ b/routers/api/packages/pub/pub.go @@ -274,7 +274,7 @@ func DownloadPackageFile(ctx *context.Context) { pf := pd.Files[0].File - s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + s, u, _, err := packages_service.OpenFileForDownload(ctx, pf) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/packages/pypi/pypi.go b/routers/api/packages/pypi/pypi.go index 199f4e7478..9b5ae6c89d 100644 --- a/routers/api/packages/pypi/pypi.go +++ b/routers/api/packages/pypi/pypi.go @@ -82,7 +82,7 @@ func DownloadPackageFile(ctx *context.Context) { packageVersion := ctx.PathParam("version") filename := ctx.PathParam("filename") - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/rpm/rpm.go b/routers/api/packages/rpm/rpm.go index a00a61c079..938c35341d 100644 --- a/routers/api/packages/rpm/rpm.go +++ b/routers/api/packages/rpm/rpm.go @@ -96,7 +96,7 @@ func GetRepositoryFile(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion( ctx, pv, &packages_service.PackageFileInfo{ @@ -220,7 +220,7 @@ func DownloadPackageFile(ctx *context.Context) { name := ctx.PathParam("name") version := ctx.PathParam("version") - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/packages/rubygems/rubygems.go b/routers/api/packages/rubygems/rubygems.go index de8c7ef3ed..774d5520fd 100644 --- a/routers/api/packages/rubygems/rubygems.go +++ b/routers/api/packages/rubygems/rubygems.go @@ -14,6 +14,7 @@ import ( "strings" packages_model "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/optional" packages_module "code.gitea.io/gitea/modules/packages" rubygems_module "code.gitea.io/gitea/modules/packages/rubygems" @@ -177,7 +178,7 @@ func DownloadPackageFile(ctx *context.Context) { return } - s, u, pf, err := packages_service.GetFileStreamByPackageVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageVersion( ctx, pvs[0], &packages_service.PackageFileInfo{ @@ -309,7 +310,7 @@ func GetPackageInfo(ctx *context.Context) { apiError(ctx, http.StatusNotFound, nil) return } - infoContent, err := makePackageInfo(ctx, versions) + infoContent, err := makePackageInfo(ctx, versions, cache.NewEphemeralCache()) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -317,7 +318,7 @@ func GetPackageInfo(ctx *context.Context) { ctx.PlainText(http.StatusOK, infoContent) } -// GetAllPackagesVersions returns a custom text based format containing information about all versions of all rubygems. +// GetAllPackagesVersions returns a custom text-based format containing information about all versions of all rubygems. // ref: https://guides.rubygems.org/rubygems-org-compact-index-api/ func GetAllPackagesVersions(ctx *context.Context) { packages, err := packages_model.GetPackagesByType(ctx, ctx.Package.Owner.ID, packages_model.TypeRubyGems) @@ -326,6 +327,7 @@ func GetAllPackagesVersions(ctx *context.Context) { return } + ephemeralCache := cache.NewEphemeralCache() out := &strings.Builder{} out.WriteString("---\n") for _, pkg := range packages { @@ -338,7 +340,7 @@ func GetAllPackagesVersions(ctx *context.Context) { continue } - info, err := makePackageInfo(ctx, versions) + info, err := makePackageInfo(ctx, versions, ephemeralCache) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return @@ -348,7 +350,14 @@ func GetAllPackagesVersions(ctx *context.Context) { _, _ = fmt.Fprintf(out, "%s ", pkg.Name) for i, v := range versions { sep := util.Iif(i == len(versions)-1, "", ",") - _, _ = fmt.Fprintf(out, "%s%s", v.Version, sep) + pd, err := packages_model.GetPackageDescriptorWithCache(ctx, v, ephemeralCache) + if errors.Is(err, util.ErrNotExist) { + continue + } else if err != nil { + apiError(ctx, http.StatusInternalServerError, err) + return + } + writePackageVersionForList(pd.Metadata, v.Version, sep, out) } _, _ = fmt.Fprintf(out, " %x\n", md5.Sum([]byte(info))) } @@ -356,6 +365,16 @@ func GetAllPackagesVersions(ctx *context.Context) { ctx.PlainText(http.StatusOK, out.String()) } +func writePackageVersionForList(metadata any, version, sep string, out *strings.Builder) { + if metadata, _ := metadata.(*rubygems_module.Metadata); metadata != nil && metadata.Platform != "" && metadata.Platform != "ruby" { + // VERSION_PLATFORM (see comment above in GetAllPackagesVersions) + _, _ = fmt.Fprintf(out, "%s_%s%s", version, metadata.Platform, sep) + } else { + // VERSION only + _, _ = fmt.Fprintf(out, "%s%s", version, sep) + } +} + func writePackageVersionRequirements(prefix string, reqs []rubygems_module.VersionRequirement, out *strings.Builder) { out.WriteString(prefix) if len(reqs) == 0 { @@ -367,11 +386,21 @@ func writePackageVersionRequirements(prefix string, reqs []rubygems_module.Versi } } -func makePackageVersionDependency(ctx *context.Context, version *packages_model.PackageVersion) (string, error) { +func writePackageVersionForDependency(version, platform string, out *strings.Builder) { + if platform != "" && platform != "ruby" { + // VERSION-PLATFORM (see comment below in makePackageVersionDependency) + _, _ = fmt.Fprintf(out, "%s-%s ", version, platform) + } else { + // VERSION only + _, _ = fmt.Fprintf(out, "%s ", version) + } +} + +func makePackageVersionDependency(ctx *context.Context, version *packages_model.PackageVersion, c *cache.EphemeralCache) (string, error) { // format: VERSION[-PLATFORM] [DEPENDENCY[,DEPENDENCY,...]]|REQUIREMENT[,REQUIREMENT,...] // DEPENDENCY: GEM:CONSTRAINT[&CONSTRAINT] // REQUIREMENT: KEY:VALUE (always contains "checksum") - pd, err := packages_model.GetPackageDescriptor(ctx, version) + pd, err := packages_model.GetPackageDescriptorWithCache(ctx, version, c) if err != nil { return "", err } @@ -388,8 +417,7 @@ func makePackageVersionDependency(ctx *context.Context, version *packages_model. } buf := &strings.Builder{} - buf.WriteString(version.Version) - buf.WriteByte(' ') + writePackageVersionForDependency(version.Version, metadata.Platform, buf) for i, dep := range metadata.RuntimeDependencies { sep := util.Iif(i == 0, "", ",") writePackageVersionRequirements(fmt.Sprintf("%s%s:", sep, dep.Name), dep.Version, buf) @@ -404,10 +432,10 @@ func makePackageVersionDependency(ctx *context.Context, version *packages_model. return buf.String(), nil } -func makePackageInfo(ctx *context.Context, versions []*packages_model.PackageVersion) (string, error) { +func makePackageInfo(ctx *context.Context, versions []*packages_model.PackageVersion, c *cache.EphemeralCache) (string, error) { ret := "---\n" for _, v := range versions { - dep, err := makePackageVersionDependency(ctx, v) + dep, err := makePackageVersionDependency(ctx, v, c) if err != nil { return "", err } diff --git a/routers/api/packages/rubygems/rubygems_test.go b/routers/api/packages/rubygems/rubygems_test.go new file mode 100644 index 0000000000..a07e12a7d3 --- /dev/null +++ b/routers/api/packages/rubygems/rubygems_test.go @@ -0,0 +1,41 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package rubygems + +import ( + "strings" + "testing" + + rubygems_module "code.gitea.io/gitea/modules/packages/rubygems" + + "github.com/stretchr/testify/assert" +) + +func TestWritePackageVersion(t *testing.T) { + buf := &strings.Builder{} + + writePackageVersionForList(nil, "1.0", " ", buf) + assert.Equal(t, "1.0 ", buf.String()) + buf.Reset() + + writePackageVersionForList(&rubygems_module.Metadata{Platform: "ruby"}, "1.0", " ", buf) + assert.Equal(t, "1.0 ", buf.String()) + buf.Reset() + + writePackageVersionForList(&rubygems_module.Metadata{Platform: "linux"}, "1.0", " ", buf) + assert.Equal(t, "1.0_linux ", buf.String()) + buf.Reset() + + writePackageVersionForDependency("1.0", "", buf) + assert.Equal(t, "1.0 ", buf.String()) + buf.Reset() + + writePackageVersionForDependency("1.0", "ruby", buf) + assert.Equal(t, "1.0 ", buf.String()) + buf.Reset() + + writePackageVersionForDependency("1.0", "os", buf) + assert.Equal(t, "1.0-os ", buf.String()) + buf.Reset() +} diff --git a/routers/api/packages/swift/swift.go b/routers/api/packages/swift/swift.go index 47439c4c3b..bf542f33a7 100644 --- a/routers/api/packages/swift/swift.go +++ b/routers/api/packages/swift/swift.go @@ -429,7 +429,7 @@ func DownloadPackageFile(ctx *context.Context) { pf := pd.Files[0].File - s, u, _, err := packages_service.GetPackageFileStream(ctx, pf) + s, u, _, err := packages_service.OpenFileForDownload(ctx, pf) if err != nil { apiError(ctx, http.StatusInternalServerError, err) return diff --git a/routers/api/packages/vagrant/vagrant.go b/routers/api/packages/vagrant/vagrant.go index 3afaa5de1f..9eb67e5397 100644 --- a/routers/api/packages/vagrant/vagrant.go +++ b/routers/api/packages/vagrant/vagrant.go @@ -218,7 +218,7 @@ func UploadPackageFile(ctx *context.Context) { } func DownloadPackageFile(ctx *context.Context) { - s, u, pf, err := packages_service.GetFileStreamByPackageNameAndVersion( + s, u, pf, err := packages_service.OpenFileForDownloadByPackageNameAndVersion( ctx, &packages_service.PackageInfo{ Owner: ctx.Package.Owner, diff --git a/routers/api/v1/admin/action.go b/routers/api/v1/admin/action.go new file mode 100644 index 0000000000..2fbb8e1a95 --- /dev/null +++ b/routers/api/v1/admin/action.go @@ -0,0 +1,93 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// ListWorkflowJobs Lists all jobs +func ListWorkflowJobs(ctx *context.APIContext) { + // swagger:operation GET /admin/actions/jobs admin listAdminWorkflowJobs + // --- + // summary: Lists all jobs + // produces: + // - application/json + // parameters: + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowJobsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + shared.ListJobs(ctx, 0, 0, 0) +} + +// ListWorkflowRuns Lists all runs +func ListWorkflowRuns(ctx *context.APIContext) { + // swagger:operation GET /admin/actions/runs admin listAdminWorkflowRuns + // --- + // summary: Lists all runs + // produces: + // - application/json + // parameters: + // - name: event + // in: query + // description: workflow event name + // type: string + // required: false + // - name: branch + // in: query + // description: workflow branch + // type: string + // required: false + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: actor + // in: query + // description: triggered by user + // type: string + // required: false + // - name: head_sha + // in: query + // description: triggering sha of the workflow run + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowRunsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + shared.ListRuns(ctx, 0, 0) +} diff --git a/routers/api/v1/admin/org.go b/routers/api/v1/admin/org.go index 8808a1587d..c3473372f2 100644 --- a/routers/api/v1/admin/org.go +++ b/routers/api/v1/admin/org.go @@ -29,7 +29,7 @@ func CreateOrg(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of the user that will own the created organization + // description: username of the user who will own the created organization // type: string // required: true // - name: organization @@ -101,7 +101,7 @@ func GetAllOrgs(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeOrganization, OrderBy: db.SearchOrderByAlphabetically, diff --git a/routers/api/v1/admin/repo.go b/routers/api/v1/admin/repo.go index c119d5390a..12a78c9c4b 100644 --- a/routers/api/v1/admin/repo.go +++ b/routers/api/v1/admin/repo.go @@ -22,7 +22,7 @@ func CreateRepo(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of the user. This user will own the created repository + // description: username of the user who will own the created repository // type: string // required: true // - name: repository diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 3ba77604ec..8a267cc418 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -175,7 +175,7 @@ func EditUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to edit + // description: username of the user whose data is to be edited // type: string // required: true // - name: body @@ -239,7 +239,7 @@ func EditUser(ctx *context.APIContext) { Location: optional.FromPtr(form.Location), Description: optional.FromPtr(form.Description), IsActive: optional.FromPtr(form.Active), - IsAdmin: optional.FromPtr(form.Admin), + IsAdmin: user_service.UpdateOptionFieldFromPtr(form.Admin), Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), AllowGitHook: optional.FromPtr(form.AllowGitHook), AllowImportLocal: optional.FromPtr(form.AllowImportLocal), @@ -272,7 +272,7 @@ func DeleteUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to delete + // description: username of the user to delete // type: string // required: true // - name: purge @@ -328,7 +328,7 @@ func CreatePublicKey(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of the user + // description: username of the user who is to receive a public key // type: string // required: true // - name: key @@ -358,7 +358,7 @@ func DeleteUserPublicKey(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose public key is to be deleted // type: string // required: true // - name: id @@ -405,7 +405,7 @@ func SearchUsers(ctx *context.APIContext) { // format: int64 // - name: login_name // in: query - // description: user's login name to search for + // description: identifier of the user, provided by the external authenticator // type: string // - name: page // in: query @@ -423,7 +423,7 @@ func SearchUsers(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + users, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Type: user_model.UserTypeIndividual, LoginName: ctx.FormTrim("login_name"), @@ -456,7 +456,7 @@ func RenameUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: existing username of user + // description: current username of the user // type: string // required: true // - name: body diff --git a/routers/api/v1/admin/user_badge.go b/routers/api/v1/admin/user_badge.go index 6d9665a72b..ce32f455b0 100644 --- a/routers/api/v1/admin/user_badge.go +++ b/routers/api/v1/admin/user_badge.go @@ -22,7 +22,7 @@ func ListUserBadges(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose badges are to be listed // type: string // required: true // responses: @@ -53,7 +53,7 @@ func AddUserBadges(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user to whom a badge is to be added // type: string // required: true // - name: body @@ -87,7 +87,7 @@ func DeleteUserBadges(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose badge is to be deleted // type: string // required: true // - name: body diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index b98863b418..4a4bf12657 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -228,7 +228,7 @@ func repoAssignment() func(ctx *context.APIContext) { } } - if !ctx.Repo.Permission.HasAnyUnitAccess() { + if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() { ctx.APIErrorNotFound() return } @@ -455,15 +455,6 @@ func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { } } -// reqRepoBranchWriter user should have a permission to write to a branch, or be a site admin -func reqRepoBranchWriter(ctx *context.APIContext) { - options, ok := web.GetForm(ctx).(api.FileOptionInterface) - if !ok || (!ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, options.Branch()) && !ctx.IsUserSiteAdmin()) { - ctx.APIError(http.StatusForbidden, "user should have a permission to write to this branch") - return - } -} - // reqRepoReader user should have specific read permission or be a repo admin or a site admin func reqRepoReader(unitType unit.Type) func(ctx *context.APIContext) { return func(ctx *context.APIContext) { @@ -744,9 +735,17 @@ func mustEnableWiki(ctx *context.APIContext) { } } +// FIXME: for consistency, maybe most mustNotBeArchived checks should be replaced with mustEnableEditor func mustNotBeArchived(ctx *context.APIContext) { if ctx.Repo.Repository.IsArchived { - ctx.APIError(http.StatusLocked, fmt.Errorf("%s is archived", ctx.Repo.Repository.LogString())) + ctx.APIError(http.StatusLocked, fmt.Errorf("%s is archived", ctx.Repo.Repository.FullName())) + return + } +} + +func mustEnableEditor(ctx *context.APIContext) { + if !ctx.Repo.Repository.CanEnableEditor() { + ctx.APIError(http.StatusLocked, fmt.Errorf("%s is not allowed to edit", ctx.Repo.Repository.FullName())) return } } @@ -942,6 +941,8 @@ func Routes() *web.Router { m.Get("/{runner_id}", reqToken(), reqChecker, act.GetRunner) m.Delete("/{runner_id}", reqToken(), reqChecker, act.DeleteRunner) }) + m.Get("/runs", reqToken(), reqChecker, act.ListWorkflowRuns) + m.Get("/jobs", reqToken(), reqChecker, act.ListWorkflowJobs) }) } @@ -971,7 +972,8 @@ func Routes() *web.Router { // Misc (public accessible) m.Group("", func() { m.Get("/version", misc.Version) - m.Get("/signing-key.gpg", misc.SigningKey) + m.Get("/signing-key.gpg", misc.SigningKeyGPG) + m.Get("/signing-key.pub", misc.SigningKeySSH) m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup) m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown) m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw) @@ -1077,6 +1079,9 @@ func Routes() *web.Router { m.Get("/{runner_id}", reqToken(), user.GetRunner) m.Delete("/{runner_id}", reqToken(), user.DeleteRunner) }) + + m.Get("/runs", reqToken(), user.ListWorkflowRuns) + m.Get("/jobs", reqToken(), user.ListWorkflowJobs) }) m.Get("/followers", user.ListMyFollowers) @@ -1201,6 +1206,7 @@ func Routes() *web.Router { }, context.ReferencesGitRepo(), reqToken(), reqRepoReader(unit.TypeActions)) m.Group("/actions/jobs", func() { + m.Get("/{job_id}", repo.GetWorkflowJob) m.Get("/{job_id}/logs", repo.DownloadActionsRunJobLogs) }, reqToken(), reqRepoReader(unit.TypeActions)) @@ -1241,7 +1247,7 @@ func Routes() *web.Router { }, reqToken()) m.Get("/raw/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFile) m.Get("/media/*", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetRawFileOrLFS) - m.Get("/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive) + m.Methods("HEAD,GET", "/archive/*", reqRepoReader(unit.TypeCode), repo.GetArchive) m.Combo("/forks").Get(repo.ListForks). Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork) m.Post("/merge-upstream", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeCode), bind(api.MergeUpstreamRequest{}), repo.MergeUpstream) @@ -1279,7 +1285,14 @@ func Routes() *web.Router { }, reqToken(), reqAdmin()) m.Group("/actions", func() { m.Get("/tasks", repo.ListActionTasks) - m.Get("/runs/{run}/artifacts", repo.GetArtifactsOfRun) + m.Group("/runs", func() { + m.Group("/{run}", func() { + m.Get("", repo.GetWorkflowRun) + m.Delete("", reqToken(), reqRepoWriter(unit.TypeActions), repo.DeleteActionRun) + m.Get("/jobs", repo.ListWorkflowRunJobs) + m.Get("/artifacts", repo.GetArtifactsOfRun) + }) + }) m.Get("/artifacts", repo.GetArtifacts) m.Group("/artifacts/{artifact_id}", func() { m.Get("", repo.GetArtifact) @@ -1410,21 +1423,29 @@ func Routes() *web.Router { m.Get("/tags/{sha}", repo.GetAnnotatedTag) m.Get("/notes/{sha}", repo.GetNote) }, context.ReferencesGitRepo(true), reqRepoReader(unit.TypeCode)) - m.Post("/diffpatch", reqRepoWriter(unit.TypeCode), reqToken(), bind(api.ApplyDiffPatchFileOptions{}), mustNotBeArchived, repo.ApplyDiffPatch) m.Group("/contents", func() { m.Get("", repo.GetContentsList) m.Get("/*", repo.GetContents) - m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.ChangeFiles) - m.Group("/*", func() { - m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.CreateFile) - m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.UpdateFile) - m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, repo.DeleteFile) - }, reqToken()) + m.Group("", func() { + // "change file" operations, need permission to write to the target branch provided by the form + m.Post("", bind(api.ChangeFilesOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.ChangeFiles) + m.Group("/*", func() { + m.Post("", bind(api.CreateFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.CreateFile) + m.Put("", bind(api.UpdateFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.UpdateFile) + m.Delete("", bind(api.DeleteFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.DeleteFile) + }) + m.Post("/diffpatch", bind(api.ApplyDiffPatchFileOptions{}), repo.ReqChangeRepoFileOptionsAndCheck, repo.ApplyDiffPatch) + }, mustEnableEditor, reqToken()) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) + m.Group("/contents-ext", func() { + m.Get("", repo.GetContentsExt) + m.Get("/*", repo.GetContentsExt) }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) m.Combo("/file-contents", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()). Get(repo.GetFileContentsGet). - Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // POST method requires "write" permission, so we also support "GET" method above - m.Get("/signing-key.gpg", misc.SigningKey) + Post(bind(api.GetFilesOptions{}), repo.GetFileContentsPost) // the POST method requires "write" permission, so we also support "GET" method above + m.Get("/signing-key.gpg", misc.SigningKeyGPG) + m.Get("/signing-key.pub", misc.SigningKeySSH) m.Group("/topics", func() { m.Combo("").Get(repo.ListTopics). Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics) @@ -1445,7 +1466,7 @@ func Routes() *web.Router { m.Delete("", repo.DeleteAvatar) }, reqAdmin(), reqToken()) - m.Get("/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) + m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), repo.DownloadArchive) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) @@ -1729,11 +1750,15 @@ func Routes() *web.Router { Patch(bind(api.EditHookOption{}), admin.EditHook). Delete(admin.DeleteHook) }) - m.Group("/actions/runners", func() { - m.Get("", admin.ListRunners) - m.Post("/registration-token", admin.CreateRegistrationToken) - m.Get("/{runner_id}", admin.GetRunner) - m.Delete("/{runner_id}", admin.DeleteRunner) + m.Group("/actions", func() { + m.Group("/runners", func() { + m.Get("", admin.ListRunners) + m.Post("/registration-token", admin.CreateRegistrationToken) + m.Get("/{runner_id}", admin.GetRunner) + m.Delete("/{runner_id}", admin.DeleteRunner) + }) + m.Get("/runs", admin.ListWorkflowRuns) + m.Get("/jobs", admin.ListWorkflowJobs) }) m.Group("/runners", func() { m.Get("/registration-token", admin.GetRegistrationToken) diff --git a/routers/api/v1/misc/markup.go b/routers/api/v1/misc/markup.go index 0cd4b8c5c5..909310b4c8 100644 --- a/routers/api/v1/misc/markup.go +++ b/routers/api/v1/misc/markup.go @@ -42,7 +42,7 @@ func Markup(ctx *context.APIContext) { return } - mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck + mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck // form.Wiki is deprecated common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, form.FilePath) } @@ -73,7 +73,7 @@ func Markdown(ctx *context.APIContext) { return } - mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck + mode := util.Iif(form.Wiki, "wiki", form.Mode) //nolint:staticcheck // form.Wiki is deprecated common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, "") } diff --git a/routers/api/v1/misc/signing.go b/routers/api/v1/misc/signing.go index 667396e39c..db70e04b8f 100644 --- a/routers/api/v1/misc/signing.go +++ b/routers/api/v1/misc/signing.go @@ -4,14 +4,35 @@ package misc import ( - "fmt" - + "code.gitea.io/gitea/modules/git" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" ) -// SigningKey returns the public key of the default signing key if it exists -func SigningKey(ctx *context.APIContext) { +func getSigningKey(ctx *context.APIContext, expectedFormat string) { + // if the handler is in the repo's route group, get the repo's signing key + // otherwise, get the global signing key + path := "" + if ctx.Repo != nil && ctx.Repo.Repository != nil { + path = ctx.Repo.Repository.RepoPath() + } + content, format, err := asymkey_service.PublicSigningKey(ctx, path) + if err != nil { + ctx.APIErrorInternal(err) + return + } + if format == "" { + ctx.APIErrorNotFound("no signing key") + return + } else if format != expectedFormat { + ctx.APIErrorNotFound("signing key format is " + format) + return + } + _, _ = ctx.Write([]byte(content)) +} + +// SigningKeyGPG returns the public key of the default signing key if it exists +func SigningKeyGPG(ctx *context.APIContext) { // swagger:operation GET /signing-key.gpg miscellaneous getSigningKey // --- // summary: Get default signing-key.gpg @@ -44,19 +65,42 @@ func SigningKey(ctx *context.APIContext) { // description: "GPG armored public key" // schema: // type: string + getSigningKey(ctx, git.SigningKeyFormatOpenPGP) +} - path := "" - if ctx.Repo != nil && ctx.Repo.Repository != nil { - path = ctx.Repo.Repository.RepoPath() - } +// SigningKeySSH returns the public key of the default signing key if it exists +func SigningKeySSH(ctx *context.APIContext) { + // swagger:operation GET /signing-key.pub miscellaneous getSigningKeySSH + // --- + // summary: Get default signing-key.pub + // produces: + // - text/plain + // responses: + // "200": + // description: "ssh public key" + // schema: + // type: string - content, err := asymkey_service.PublicSigningKey(ctx, path) - if err != nil { - ctx.APIErrorInternal(err) - return - } - _, err = ctx.Write([]byte(content)) - if err != nil { - ctx.APIErrorInternal(fmt.Errorf("Error writing key content %w", err)) - } + // swagger:operation GET /repos/{owner}/{repo}/signing-key.pub repository repoSigningKeySSH + // --- + // summary: Get signing-key.pub for given repository + // produces: + // - text/plain + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // description: "ssh public key" + // schema: + // type: string + getSigningKey(ctx, git.SigningKeyFormatSSH) } diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go index 700a5ef8ea..3ae5e60585 100644 --- a/routers/api/v1/org/action.go +++ b/routers/api/v1/org/action.go @@ -384,13 +384,13 @@ func (Action) CreateVariable(ctx *context.APIContext) { // "$ref": "#/definitions/CreateVariableOption" // responses: // "201": - // description: response when creating an org-level variable - // "204": - // description: response when creating an org-level variable + // description: successfully created the org-level variable // "400": // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" + // "409": + // description: variable name already exists. + // "500": + // "$ref": "#/responses/error" opt := web.GetForm(ctx).(*api.CreateVariableOption) @@ -419,7 +419,7 @@ func (Action) CreateVariable(ctx *context.APIContext) { return } - ctx.Status(http.StatusNoContent) + ctx.Status(http.StatusCreated) } // UpdateVariable update an org-level variable @@ -570,6 +570,96 @@ func (Action) DeleteRunner(ctx *context.APIContext) { shared.DeleteRunner(ctx, ctx.Org.Organization.ID, 0, ctx.PathParamInt64("runner_id")) } +func (Action) ListWorkflowJobs(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/jobs organization getOrgWorkflowJobs + // --- + // summary: Get org-level workflow jobs + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowJobsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.ListJobs(ctx, ctx.Org.Organization.ID, 0, 0) +} + +func (Action) ListWorkflowRuns(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/runs organization getOrgWorkflowRuns + // --- + // summary: Get org-level workflow runs + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: event + // in: query + // description: workflow event name + // type: string + // required: false + // - name: branch + // in: query + // description: workflow branch + // type: string + // required: false + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: actor + // in: query + // description: triggered by user + // type: string + // required: false + // - name: head_sha + // in: query + // description: triggering sha of the workflow run + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowRunsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.ListRuns(ctx, ctx.Org.Organization.ID, 0) +} + var _ actions_service.API = new(Action) // Action implements actions_service.API diff --git a/routers/api/v1/org/block.go b/routers/api/v1/org/block.go index 69a5222a20..6b2f3dc615 100644 --- a/routers/api/v1/org/block.go +++ b/routers/api/v1/org/block.go @@ -47,7 +47,7 @@ func CheckUserBlock(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: user to check + // description: username of the user to check // type: string // required: true // responses: @@ -71,7 +71,7 @@ func BlockUser(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: user to block + // description: username of the user to block // type: string // required: true // - name: note @@ -101,7 +101,7 @@ func UnblockUser(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: user to unblock + // description: username of the user to unblock // type: string // required: true // responses: diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go index a1875a7886..1c12b0cc94 100644 --- a/routers/api/v1/org/member.go +++ b/routers/api/v1/org/member.go @@ -133,7 +133,7 @@ func IsMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user to check for an organization membership // type: string // required: true // responses: @@ -186,7 +186,7 @@ func IsPublicMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user to check for a public organization membership // type: string // required: true // responses: @@ -240,7 +240,7 @@ func PublicizeMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user whose membership is to be publicized // type: string // required: true // responses: @@ -282,7 +282,7 @@ func ConcealMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user whose membership is to be concealed // type: string // required: true // responses: @@ -324,7 +324,7 @@ func DeleteMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user + // description: username of the user to remove from the organization // type: string // required: true // responses: diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index c9208f4757..05744ba155 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -26,12 +26,10 @@ import ( func listUserOrgs(ctx *context.APIContext, u *user_model.User) { listOptions := utils.GetListOptions(ctx) - showPrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == u.ID) - opts := organization.FindOrgOptions{ - ListOptions: listOptions, - UserID: u.ID, - IncludePrivate: showPrivate, + ListOptions: listOptions, + UserID: u.ID, + IncludeVisibility: organization.DoerViewOtherVisibility(ctx.Doer, u), } orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts) if err != nil { @@ -84,7 +82,7 @@ func ListUserOrgs(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose organizations are to be listed // type: string // required: true // - name: page @@ -114,7 +112,7 @@ func GetUserOrgsPermissions(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose permissions are to be obtained // type: string // required: true // - name: org @@ -201,7 +199,7 @@ func GetAll(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) - publicOrgs, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + publicOrgs, maxResults, err := user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, ListOptions: listOptions, Type: user_model.UserTypeOrganization, diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go index 71c21f2dde..1a1710750a 100644 --- a/routers/api/v1/org/team.go +++ b/routers/api/v1/org/team.go @@ -426,7 +426,7 @@ func GetTeamMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the member to list + // description: username of the user whose data is to be listed // type: string // required: true // responses: @@ -467,7 +467,7 @@ func AddTeamMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user to add + // description: username of the user to add to a team // type: string // required: true // responses: @@ -509,7 +509,7 @@ func RemoveTeamMember(ctx *context.APIContext) { // required: true // - name: username // in: path - // description: username of the user to remove + // description: username of the user to remove from a team // type: string // required: true // responses: diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go index 6aef529f98..a57db015f0 100644 --- a/routers/api/v1/repo/action.go +++ b/routers/api/v1/repo/action.go @@ -339,12 +339,12 @@ func (Action) CreateVariable(ctx *context.APIContext) { // responses: // "201": // description: response when creating a repo-level variable - // "204": - // description: response when creating a repo-level variable // "400": // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" + // "409": + // description: variable name already exists. + // "500": + // "$ref": "#/responses/error" opt := web.GetForm(ctx).(*api.CreateVariableOption) @@ -373,7 +373,7 @@ func (Action) CreateVariable(ctx *context.APIContext) { return } - ctx.Status(http.StatusNoContent) + ctx.Status(http.StatusCreated) } // UpdateVariable update a repo-level variable @@ -650,6 +650,114 @@ func (Action) DeleteRunner(ctx *context.APIContext) { shared.DeleteRunner(ctx, 0, ctx.Repo.Repository.ID, ctx.PathParamInt64("runner_id")) } +// GetWorkflowRunJobs Lists all jobs for a workflow run. +func (Action) ListWorkflowJobs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/jobs repository listWorkflowJobs + // --- + // summary: Lists all jobs for a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowJobsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repoID := ctx.Repo.Repository.ID + + shared.ListJobs(ctx, 0, repoID, 0) +} + +// ListWorkflowRuns Lists all runs for a repository run. +func (Action) ListWorkflowRuns(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs repository getWorkflowRuns + // --- + // summary: Lists all runs for a repository run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: event + // in: query + // description: workflow event name + // type: string + // required: false + // - name: branch + // in: query + // description: workflow branch + // type: string + // required: false + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: actor + // in: query + // description: triggered by user + // type: string + // required: false + // - name: head_sha + // in: query + // description: triggering sha of the workflow run + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/ArtifactsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repoID := ctx.Repo.Repository.ID + + shared.ListRuns(ctx, 0, repoID) +} + var _ actions_service.API = new(Action) // Action implements actions_service.API @@ -756,7 +864,7 @@ func ActionsListRepositoryWorkflows(ctx *context.APIContext) { // "500": // "$ref": "#/responses/error" - workflows, err := actions_service.ListActionWorkflows(ctx) + workflows, err := convert.ListActionWorkflows(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository) if err != nil { ctx.APIErrorInternal(err) return @@ -802,7 +910,7 @@ func ActionsGetWorkflow(ctx *context.APIContext) { // "$ref": "#/responses/error" workflowID := ctx.PathParam("workflow_id") - workflow, err := actions_service.GetActionWorkflow(ctx, workflowID) + workflow, err := convert.GetActionWorkflow(ctx, ctx.Repo.GitRepo, ctx.Repo.Repository, workflowID) if err != nil { if errors.Is(err, util.ErrNotExist) { ctx.APIError(http.StatusNotFound, err) @@ -992,6 +1100,157 @@ func ActionsEnableWorkflow(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } +// GetWorkflowRun Gets a specific workflow run. +func GetWorkflowRun(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run} repository GetWorkflowRun + // --- + // summary: Gets a specific workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: id of the run + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/WorkflowRun" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + runID := ctx.PathParamInt64("run") + job, _, err := db.GetByID[actions_model.ActionRun](ctx, runID) + + if err != nil || job.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusNotFound, util.ErrNotExist) + } + + convertedArtifact, err := convert.ToActionWorkflowRun(ctx, ctx.Repo.Repository, job) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedArtifact) +} + +// ListWorkflowRunJobs Lists all jobs for a workflow run. +func ListWorkflowRunJobs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/jobs repository listWorkflowRunJobs + // --- + // summary: Lists all jobs for a workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: runid of the workflow run + // type: integer + // required: true + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WorkflowJobsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repoID := ctx.Repo.Repository.ID + + runID := ctx.PathParamInt64("run") + + // Avoid the list all jobs functionality for this api route to be used with a runID == 0. + if runID <= 0 { + ctx.APIError(http.StatusBadRequest, util.NewInvalidArgumentErrorf("runID must be a positive integer")) + return + } + + // runID is used as an additional filter next to repoID to ensure that we only list jobs for the specified repoID and runID. + // no additional checks for runID are needed here + shared.ListJobs(ctx, 0, repoID, runID) +} + +// GetWorkflowJob Gets a specific workflow job for a workflow run. +func GetWorkflowJob(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/jobs/{job_id} repository getWorkflowJob + // --- + // summary: Gets a specific workflow job for a workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: job_id + // in: path + // description: id of the job + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/WorkflowJob" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + jobID := ctx.PathParamInt64("job_id") + job, _, err := db.GetByID[actions_model.ActionRunJob](ctx, jobID) + + if err != nil || job.RepoID != ctx.Repo.Repository.ID { + ctx.APIError(http.StatusNotFound, util.ErrNotExist) + } + + convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, ctx.Repo.Repository, nil, job) + if err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.JSON(http.StatusOK, convertedWorkflowJob) +} + // GetArtifacts Lists all artifacts for a repository. func GetArtifactsOfRun(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/runs/{run}/artifacts repository getArtifactsOfRun @@ -1061,6 +1320,58 @@ func GetArtifactsOfRun(ctx *context.APIContext) { ctx.JSON(http.StatusOK, &res) } +// DeleteActionRun Delete a workflow run +func DeleteActionRun(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/runs/{run} repository deleteActionRun + // --- + // summary: Delete a workflow run + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: name of the owner + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: run + // in: path + // description: runid of the workflow run + // type: integer + // required: true + // responses: + // "204": + // description: "No Content" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + runID := ctx.PathParamInt64("run") + run, err := actions_model.GetRunByRepoAndID(ctx, ctx.Repo.Repository.ID, runID) + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(http.StatusNotFound, err) + return + } else if err != nil { + ctx.APIErrorInternal(err) + return + } + if !run.Status.IsDone() { + ctx.APIError(http.StatusBadRequest, "this workflow run is not done") + return + } + + if err := actions_service.DeleteRun(ctx, run); err != nil { + ctx.APIErrorInternal(err) + return + } + ctx.Status(http.StatusNoContent) +} + // GetArtifacts Lists all artifacts for a repository. func GetArtifacts(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/actions/artifacts repository getArtifacts diff --git a/routers/api/v1/repo/blob.go b/routers/api/v1/repo/blob.go index d1cb72f5f1..9a17fc1bbf 100644 --- a/routers/api/v1/repo/blob.go +++ b/routers/api/v1/repo/blob.go @@ -47,7 +47,7 @@ func GetBlob(ctx *context.APIContext) { return } - if blob, err := files_service.GetBlobBySHA(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, sha); err != nil { + if blob, err := files_service.GetBlobBySHA(ctx.Repo.Repository, ctx.Repo.GitRepo, sha); err != nil { ctx.APIError(http.StatusBadRequest, err) } else { ctx.JSON(http.StatusOK, blob) diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index fe82550fdd..9af958a5b7 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -224,9 +224,9 @@ func CreateBranch(ctx *context.APIContext) { ctx.APIErrorInternal(err) return } - } else if len(opt.OldBranchName) > 0 { //nolint - if gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, opt.OldBranchName) { //nolint - oldCommit, err = ctx.Repo.GitRepo.GetBranchCommit(opt.OldBranchName) //nolint + } else if len(opt.OldBranchName) > 0 { //nolint:staticcheck // deprecated field + if gitrepo.IsBranchExist(ctx, ctx.Repo.Repository, opt.OldBranchName) { //nolint:staticcheck // deprecated field + oldCommit, err = ctx.Repo.GitRepo.GetBranchCommit(opt.OldBranchName) //nolint:staticcheck // deprecated field if err != nil { ctx.APIErrorInternal(err) return @@ -579,7 +579,7 @@ func CreateBranchProtection(ctx *context.APIContext) { ruleName := form.RuleName if ruleName == "" { - ruleName = form.BranchName //nolint + ruleName = form.BranchName //nolint:staticcheck // deprecated field } if len(ruleName) == 0 { ctx.APIError(http.StatusBadRequest, "both rule_name and branch_name are empty") @@ -1181,7 +1181,7 @@ func MergeUpstream(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" form := web.GetForm(ctx).(*api.MergeUpstreamRequest) - mergeStyle, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, form.Branch) + mergeStyle, err := repo_service.MergeUpstream(ctx, ctx.Doer, ctx.Repo.Repository, form.Branch, form.FfOnly) if err != nil { if errors.Is(err, util.ErrInvalidArgument) { ctx.APIError(http.StatusBadRequest, err) diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go index d1652c1d51..c2c10cc695 100644 --- a/routers/api/v1/repo/collaborators.go +++ b/routers/api/v1/repo/collaborators.go @@ -93,7 +93,7 @@ func IsCollaborator(ctx *context.APIContext) { // required: true // - name: collaborator // in: path - // description: username of the collaborator + // description: username of the user to check for being a collaborator // type: string // required: true // responses: @@ -145,7 +145,7 @@ func AddOrUpdateCollaborator(ctx *context.APIContext) { // required: true // - name: collaborator // in: path - // description: username of the collaborator to add + // description: username of the user to add or update as a collaborator // type: string // required: true // - name: body @@ -264,7 +264,7 @@ func GetRepoPermissions(ctx *context.APIContext) { // required: true // - name: collaborator // in: path - // description: username of the collaborator + // description: username of the collaborator whose permissions are to be obtained // type: string // required: true // responses: diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go index 20258064a0..6a93be624f 100644 --- a/routers/api/v1/repo/commits.go +++ b/routers/api/v1/repo/commits.go @@ -8,6 +8,7 @@ import ( "math" "net/http" "strconv" + "time" issues_model "code.gitea.io/gitea/models/issues" user_model "code.gitea.io/gitea/models/user" @@ -116,6 +117,16 @@ func GetAllCommits(ctx *context.APIContext) { // in: query // description: filepath of a file/dir // type: string + // - name: since + // in: query + // description: Only commits after this date will be returned (ISO 8601 format) + // type: string + // format: date-time + // - name: until + // in: query + // description: Only commits before this date will be returned (ISO 8601 format) + // type: string + // format: date-time // - name: stat // in: query // description: include diff stats for every commit (disable for speedup, default 'true') @@ -148,6 +159,23 @@ func GetAllCommits(ctx *context.APIContext) { // "409": // "$ref": "#/responses/EmptyRepository" + since := ctx.FormString("since") + until := ctx.FormString("until") + + // Validate since/until as ISO 8601 (RFC3339) + if since != "" { + if _, err := time.Parse(time.RFC3339, since); err != nil { + ctx.APIError(http.StatusUnprocessableEntity, "invalid 'since' format, expected ISO 8601 (RFC3339)") + return + } + } + if until != "" { + if _, err := time.Parse(time.RFC3339, until); err != nil { + ctx.APIError(http.StatusUnprocessableEntity, "invalid 'until' format, expected ISO 8601 (RFC3339)") + return + } + } + if ctx.Repo.Repository.IsEmpty { ctx.JSON(http.StatusConflict, api.APIError{ Message: "Git Repository is empty.", @@ -198,6 +226,8 @@ func GetAllCommits(ctx *context.APIContext) { RepoPath: ctx.Repo.GitRepo.Path, Not: not, Revision: []string{baseCommit.ID.String()}, + Since: since, + Until: until, }) if err != nil { ctx.APIErrorInternal(err) @@ -205,7 +235,7 @@ func GetAllCommits(ctx *context.APIContext) { } // Query commits - commits, err = baseCommit.CommitsByRange(listOptions.Page, listOptions.PageSize, not) + commits, err = baseCommit.CommitsByRange(listOptions.Page, listOptions.PageSize, not, since, until) if err != nil { ctx.APIErrorInternal(err) return @@ -221,6 +251,8 @@ func GetAllCommits(ctx *context.APIContext) { Not: not, Revision: []string{sha}, RelPath: []string{path}, + Since: since, + Until: until, }) if err != nil { @@ -237,6 +269,8 @@ func GetAllCommits(ctx *context.APIContext) { File: path, Not: not, Page: listOptions.Page, + Since: since, + Until: until, }) if err != nil { ctx.APIErrorInternal(err) diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go index f40d39a251..8ce52c2cd4 100644 --- a/routers/api/v1/repo/file.go +++ b/routers/api/v1/repo/file.go @@ -62,7 +62,7 @@ func GetRawFile(ctx *context.APIContext) { // required: true // - name: ref // in: query - // description: "The name of the commit/branch/tag. Default the repository’s default branch" + // description: "The name of the commit/branch/tag. Default to the repository’s default branch" // type: string // required: false // responses: @@ -115,7 +115,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { // required: true // - name: ref // in: query - // description: "The name of the commit/branch/tag. Default the repository’s default branch" + // description: "The name of the commit/branch/tag. Default to the repository’s default branch" // type: string // required: false // responses: @@ -139,27 +139,27 @@ func GetRawFileOrLFS(ctx *context.APIContext) { ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry))) // LFS Pointer files are at most 1024 bytes - so any blob greater than 1024 bytes cannot be an LFS file - if blob.Size() > 1024 { + if blob.Size() > lfs.MetaFileMaxSize { // First handle caching for the blob if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { return } - // OK not cached - serve! + // If not cached - serve! if err := common.ServeBlob(ctx.Base, ctx.Repo.Repository, ctx.Repo.TreePath, blob, lastModified); err != nil { ctx.APIErrorInternal(err) } return } - // OK, now the blob is known to have at most 1024 bytes we can simply read this in one go (This saves reading it twice) + // OK, now the blob is known to have at most 1024 (lfs pointer max size) bytes, + // we can simply read this in one go (This saves reading it twice) dataRc, err := blob.DataAsync() if err != nil { ctx.APIErrorInternal(err) return } - // FIXME: code from #19689, what if the file is large ... OOM ... buf, err := io.ReadAll(dataRc) if err != nil { _ = dataRc.Close() @@ -181,7 +181,7 @@ func GetRawFileOrLFS(ctx *context.APIContext) { return } - // OK not cached - serve! + // If not cached - serve! common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) return } @@ -405,13 +405,6 @@ func GetEditorconfig(ctx *context.APIContext) { ctx.JSON(http.StatusOK, def) } -// canWriteFiles returns true if repository is editable and user has proper access level. -func canWriteFiles(ctx *context.APIContext, branch string) bool { - return ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, branch) && - !ctx.Repo.Repository.IsMirror && - !ctx.Repo.Repository.IsArchived -} - func base64Reader(s string) (io.ReadSeeker, error) { b, err := base64.StdEncoding.DecodeString(s) if err != nil { @@ -420,6 +413,45 @@ func base64Reader(s string) (io.ReadSeeker, error) { return bytes.NewReader(b), nil } +func ReqChangeRepoFileOptionsAndCheck(ctx *context.APIContext) { + commonOpts := web.GetForm(ctx).(api.FileOptionsInterface).GetFileOptions() + commonOpts.BranchName = util.IfZero(commonOpts.BranchName, ctx.Repo.Repository.DefaultBranch) + commonOpts.NewBranchName = util.IfZero(commonOpts.NewBranchName, commonOpts.BranchName) + if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, commonOpts.NewBranchName) && !ctx.IsUserSiteAdmin() { + ctx.APIError(http.StatusForbidden, "user should have a permission to write to the target branch") + return + } + changeFileOpts := &files_service.ChangeRepoFilesOptions{ + Message: commonOpts.Message, + OldBranch: commonOpts.BranchName, + NewBranch: commonOpts.NewBranchName, + Committer: &files_service.IdentityOptions{ + GitUserName: commonOpts.Committer.Name, + GitUserEmail: commonOpts.Committer.Email, + }, + Author: &files_service.IdentityOptions{ + GitUserName: commonOpts.Author.Name, + GitUserEmail: commonOpts.Author.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: commonOpts.Dates.Author, + Committer: commonOpts.Dates.Committer, + }, + Signoff: commonOpts.Signoff, + } + if commonOpts.Dates.Author.IsZero() { + commonOpts.Dates.Author = time.Now() + } + if commonOpts.Dates.Committer.IsZero() { + commonOpts.Dates.Committer = time.Now() + } + ctx.Data["__APIChangeRepoFilesOptions"] = changeFileOpts +} + +func getAPIChangeRepoFileOptions[T api.FileOptionsInterface](ctx *context.APIContext) (apiOpts T, opts *files_service.ChangeRepoFilesOptions) { + return web.GetForm(ctx).(T), ctx.Data["__APIChangeRepoFilesOptions"].(*files_service.ChangeRepoFilesOptions) +} + // ChangeFiles handles API call for modifying multiple files func ChangeFiles(ctx *context.APIContext) { // swagger:operation POST /repos/{owner}/{repo}/contents repository repoChangeFiles @@ -456,20 +488,18 @@ func ChangeFiles(ctx *context.APIContext) { // "$ref": "#/responses/error" // "423": // "$ref": "#/responses/repoArchivedError" - - apiOpts := web.GetForm(ctx).(*api.ChangeFilesOptions) - - if apiOpts.BranchName == "" { - apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch + apiOpts, opts := getAPIChangeRepoFileOptions[*api.ChangeFilesOptions](ctx) + if ctx.Written() { + return } - - var files []*files_service.ChangeRepoFile for _, file := range apiOpts.Files { contentReader, err := base64Reader(file.ContentBase64) if err != nil { ctx.APIError(http.StatusUnprocessableEntity, err) return } + // FIXME: ChangeFileOperation.SHA is NOT required for update or delete if last commit is provided in the options + // But the LastCommitID is not provided in the API options, need to fully fix them in API changeRepoFile := &files_service.ChangeRepoFile{ Operation: file.Operation, TreePath: file.Path, @@ -477,41 +507,15 @@ func ChangeFiles(ctx *context.APIContext) { ContentReader: contentReader, SHA: file.SHA, } - files = append(files, changeRepoFile) - } - - opts := &files_service.ChangeRepoFilesOptions{ - Files: files, - Message: apiOpts.Message, - OldBranch: apiOpts.BranchName, - NewBranch: apiOpts.NewBranchName, - Committer: &files_service.IdentityOptions{ - GitUserName: apiOpts.Committer.Name, - GitUserEmail: apiOpts.Committer.Email, - }, - Author: &files_service.IdentityOptions{ - GitUserName: apiOpts.Author.Name, - GitUserEmail: apiOpts.Author.Email, - }, - Dates: &files_service.CommitDateOptions{ - Author: apiOpts.Dates.Author, - Committer: apiOpts.Dates.Committer, - }, - Signoff: apiOpts.Signoff, - } - if opts.Dates.Author.IsZero() { - opts.Dates.Author = time.Now() - } - if opts.Dates.Committer.IsZero() { - opts.Dates.Committer = time.Now() + opts.Files = append(opts.Files, changeRepoFile) } if opts.Message == "" { - opts.Message = changeFilesCommitMessage(ctx, files) + opts.Message = changeFilesCommitMessage(ctx, opts.Files) } - if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil { - handleCreateOrUpdateFileError(ctx, err) + if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { + handleChangeRepoFilesError(ctx, err) } else { ctx.JSON(http.StatusCreated, filesResponse) } @@ -559,56 +563,27 @@ func CreateFile(ctx *context.APIContext) { // "423": // "$ref": "#/responses/repoArchivedError" - apiOpts := web.GetForm(ctx).(*api.CreateFileOptions) - - if apiOpts.BranchName == "" { - apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch + apiOpts, opts := getAPIChangeRepoFileOptions[*api.CreateFileOptions](ctx) + if ctx.Written() { + return } - contentReader, err := base64Reader(apiOpts.ContentBase64) if err != nil { ctx.APIError(http.StatusUnprocessableEntity, err) return } - opts := &files_service.ChangeRepoFilesOptions{ - Files: []*files_service.ChangeRepoFile{ - { - Operation: "create", - TreePath: ctx.PathParam("*"), - ContentReader: contentReader, - }, - }, - Message: apiOpts.Message, - OldBranch: apiOpts.BranchName, - NewBranch: apiOpts.NewBranchName, - Committer: &files_service.IdentityOptions{ - GitUserName: apiOpts.Committer.Name, - GitUserEmail: apiOpts.Committer.Email, - }, - Author: &files_service.IdentityOptions{ - GitUserName: apiOpts.Author.Name, - GitUserEmail: apiOpts.Author.Email, - }, - Dates: &files_service.CommitDateOptions{ - Author: apiOpts.Dates.Author, - Committer: apiOpts.Dates.Committer, - }, - Signoff: apiOpts.Signoff, - } - if opts.Dates.Author.IsZero() { - opts.Dates.Author = time.Now() - } - if opts.Dates.Committer.IsZero() { - opts.Dates.Committer = time.Now() - } - + opts.Files = append(opts.Files, &files_service.ChangeRepoFile{ + Operation: "create", + TreePath: ctx.PathParam("*"), + ContentReader: contentReader, + }) if opts.Message == "" { opts.Message = changeFilesCommitMessage(ctx, opts.Files) } - if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil { - handleCreateOrUpdateFileError(ctx, err) + if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { + handleChangeRepoFilesError(ctx, err) } else { fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) ctx.JSON(http.StatusCreated, fileResponse) @@ -656,96 +631,55 @@ func UpdateFile(ctx *context.APIContext) { // "$ref": "#/responses/error" // "423": // "$ref": "#/responses/repoArchivedError" - apiOpts := web.GetForm(ctx).(*api.UpdateFileOptions) - if ctx.Repo.Repository.IsEmpty { - ctx.APIError(http.StatusUnprocessableEntity, errors.New("repo is empty")) - return - } - if apiOpts.BranchName == "" { - apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch + apiOpts, opts := getAPIChangeRepoFileOptions[*api.UpdateFileOptions](ctx) + if ctx.Written() { + return } - contentReader, err := base64Reader(apiOpts.ContentBase64) if err != nil { ctx.APIError(http.StatusUnprocessableEntity, err) return } - - opts := &files_service.ChangeRepoFilesOptions{ - Files: []*files_service.ChangeRepoFile{ - { - Operation: "update", - ContentReader: contentReader, - SHA: apiOpts.SHA, - FromTreePath: apiOpts.FromPath, - TreePath: ctx.PathParam("*"), - }, - }, - Message: apiOpts.Message, - OldBranch: apiOpts.BranchName, - NewBranch: apiOpts.NewBranchName, - Committer: &files_service.IdentityOptions{ - GitUserName: apiOpts.Committer.Name, - GitUserEmail: apiOpts.Committer.Email, - }, - Author: &files_service.IdentityOptions{ - GitUserName: apiOpts.Author.Name, - GitUserEmail: apiOpts.Author.Email, - }, - Dates: &files_service.CommitDateOptions{ - Author: apiOpts.Dates.Author, - Committer: apiOpts.Dates.Committer, - }, - Signoff: apiOpts.Signoff, - } - if opts.Dates.Author.IsZero() { - opts.Dates.Author = time.Now() - } - if opts.Dates.Committer.IsZero() { - opts.Dates.Committer = time.Now() - } - + opts.Files = append(opts.Files, &files_service.ChangeRepoFile{ + Operation: "update", + ContentReader: contentReader, + SHA: apiOpts.SHA, + FromTreePath: apiOpts.FromPath, + TreePath: ctx.PathParam("*"), + }) if opts.Message == "" { opts.Message = changeFilesCommitMessage(ctx, opts.Files) } - if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil { - handleCreateOrUpdateFileError(ctx, err) + if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { + handleChangeRepoFilesError(ctx, err) } else { fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) ctx.JSON(http.StatusOK, fileResponse) } } -func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) { +func handleChangeRepoFilesError(ctx *context.APIContext, err error) { if files_service.IsErrUserCannotCommit(err) || pull_service.IsErrFilePathProtected(err) { ctx.APIError(http.StatusForbidden, err) return } if git_model.IsErrBranchAlreadyExists(err) || files_service.IsErrFilenameInvalid(err) || pull_service.IsErrSHADoesNotMatch(err) || - files_service.IsErrFilePathInvalid(err) || files_service.IsErrRepoFileAlreadyExists(err) { + files_service.IsErrFilePathInvalid(err) || files_service.IsErrRepoFileAlreadyExists(err) || + files_service.IsErrCommitIDDoesNotMatch(err) || files_service.IsErrSHAOrCommitIDNotProvided(err) { ctx.APIError(http.StatusUnprocessableEntity, err) return } - if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) { + if git.IsErrBranchNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) { ctx.APIError(http.StatusNotFound, err) return } - - ctx.APIErrorInternal(err) -} - -// Called from both CreateFile or UpdateFile to handle both -func createOrUpdateFiles(ctx *context.APIContext, opts *files_service.ChangeRepoFilesOptions) (*api.FilesResponse, error) { - if !canWriteFiles(ctx, opts.OldBranch) { - return nil, repo_model.ErrUserDoesNotHaveAccessToRepo{ - UserID: ctx.Doer.ID, - RepoName: ctx.Repo.Repository.LowerName, - } + if errors.Is(err, util.ErrNotExist) { + ctx.APIError(http.StatusNotFound, err) + return } - - return files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts) + ctx.APIErrorInternal(err) } // format commit message if empty @@ -759,7 +693,7 @@ func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.Ch switch file.Operation { case "create": createFiles = append(createFiles, file.TreePath) - case "update": + case "update", "upload", "rename": // upload and rename works like "update", there is no translation for them at the moment updateFiles = append(updateFiles, file.TreePath) case "delete": deleteFiles = append(deleteFiles, file.TreePath) @@ -817,74 +751,27 @@ func DeleteFile(ctx *context.APIContext) { // "$ref": "#/responses/error" // "404": // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/error" // "423": // "$ref": "#/responses/repoArchivedError" - apiOpts := web.GetForm(ctx).(*api.DeleteFileOptions) - if !canWriteFiles(ctx, apiOpts.BranchName) { - ctx.APIError(http.StatusForbidden, repo_model.ErrUserDoesNotHaveAccessToRepo{ - UserID: ctx.Doer.ID, - RepoName: ctx.Repo.Repository.LowerName, - }) + apiOpts, opts := getAPIChangeRepoFileOptions[*api.DeleteFileOptions](ctx) + if ctx.Written() { return } - if apiOpts.BranchName == "" { - apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch - } - - opts := &files_service.ChangeRepoFilesOptions{ - Files: []*files_service.ChangeRepoFile{ - { - Operation: "delete", - SHA: apiOpts.SHA, - TreePath: ctx.PathParam("*"), - }, - }, - Message: apiOpts.Message, - OldBranch: apiOpts.BranchName, - NewBranch: apiOpts.NewBranchName, - Committer: &files_service.IdentityOptions{ - GitUserName: apiOpts.Committer.Name, - GitUserEmail: apiOpts.Committer.Email, - }, - Author: &files_service.IdentityOptions{ - GitUserName: apiOpts.Author.Name, - GitUserEmail: apiOpts.Author.Email, - }, - Dates: &files_service.CommitDateOptions{ - Author: apiOpts.Dates.Author, - Committer: apiOpts.Dates.Committer, - }, - Signoff: apiOpts.Signoff, - } - if opts.Dates.Author.IsZero() { - opts.Dates.Author = time.Now() - } - if opts.Dates.Committer.IsZero() { - opts.Dates.Committer = time.Now() - } - + opts.Files = append(opts.Files, &files_service.ChangeRepoFile{ + Operation: "delete", + SHA: apiOpts.SHA, + TreePath: ctx.PathParam("*"), + }) if opts.Message == "" { opts.Message = changeFilesCommitMessage(ctx, opts.Files) } if filesResponse, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts); err != nil { - if git.IsErrBranchNotExist(err) || files_service.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) { - ctx.APIError(http.StatusNotFound, err) - return - } else if git_model.IsErrBranchAlreadyExists(err) || - files_service.IsErrFilenameInvalid(err) || - pull_service.IsErrSHADoesNotMatch(err) || - files_service.IsErrCommitIDDoesNotMatch(err) || - files_service.IsErrSHAOrCommitIDNotProvided(err) { - ctx.APIError(http.StatusBadRequest, err) - return - } else if files_service.IsErrUserCannotCommit(err) { - ctx.APIError(http.StatusForbidden, err) - return - } - ctx.APIErrorInternal(err) + handleChangeRepoFilesError(ctx, err) } else { fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent @@ -902,11 +789,72 @@ func resolveRefCommit(ctx *context.APIContext, ref string, minCommitIDLen ...int return refCommit } -// GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir +func GetContentsExt(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/contents-ext/{filepath} repository repoGetContentsExt + // --- + // summary: The extended "contents" API, to get file metadata and/or content, or list a directory. + // description: It guarantees that only one of the response fields is set if the request succeeds. + // Users can pass "includes=file_content" or "includes=lfs_metadata" to retrieve more fields. + // "includes=file_content" only works for single file, if you need to retrieve file contents in batch, + // use "file-contents" API after listing the directory. + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: filepath + // in: path + // description: path of the dir, file, symlink or submodule in the repo + // type: string + // required: true + // - name: ref + // in: query + // description: the name of the commit/branch/tag, default to the repository’s default branch. + // type: string + // required: false + // - name: includes + // in: query + // description: By default this API's response only contains file's metadata. Use comma-separated "includes" options to retrieve more fields. + // Option "file_content" will try to retrieve the file content, option "lfs_metadata" will try to retrieve LFS metadata. + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ContentsExtResponse" + // "404": + // "$ref": "#/responses/notFound" + + opts := files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*")} + for includeOpt := range strings.SplitSeq(ctx.FormString("includes"), ",") { + if includeOpt == "" { + continue + } + switch includeOpt { + case "file_content": + opts.IncludeSingleFileContent = true + case "lfs_metadata": + opts.IncludeLfsMetadata = true + default: + ctx.APIError(http.StatusBadRequest, fmt.Sprintf("unknown include option %q", includeOpt)) + return + } + } + ctx.JSON(http.StatusOK, getRepoContents(ctx, opts)) +} + func GetContents(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/contents/{filepath} repository repoGetContents // --- - // summary: Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir + // summary: Gets the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir. + // description: This API follows GitHub's design, and it is not easy to use. Recommend users to use the "contents-ext" API instead. // produces: // - application/json // parameters: @@ -935,29 +883,34 @@ func GetContents(ctx *context.APIContext) { // "$ref": "#/responses/ContentsResponse" // "404": // "$ref": "#/responses/notFound" - - treePath := ctx.PathParam("*") - refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref")) + ret := getRepoContents(ctx, files_service.GetContentsOrListOptions{TreePath: ctx.PathParam("*"), IncludeSingleFileContent: true}) if ctx.Written() { return } + ctx.JSON(http.StatusOK, util.Iif[any](ret.FileContents != nil, ret.FileContents, ret.DirContents)) +} - if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, refCommit, treePath); err != nil { +func getRepoContents(ctx *context.APIContext, opts files_service.GetContentsOrListOptions) *api.ContentsExtResponse { + refCommit := resolveRefCommit(ctx, ctx.FormTrim("ref")) + if ctx.Written() { + return nil + } + ret, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts) + if err != nil { if git.IsErrNotExist(err) { ctx.APIErrorNotFound("GetContentsOrList", err) - return + return nil } ctx.APIErrorInternal(err) - } else { - ctx.JSON(http.StatusOK, fileList) } + return &ret } -// GetContentsList Get the metadata of all the entries of the root dir func GetContentsList(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/contents repository repoGetContentsList // --- - // summary: Gets the metadata of all the entries of the root dir + // summary: Gets the metadata of all the entries of the root dir. + // description: This API follows GitHub's design, and it is not easy to use. Recommend users to use our "contents-ext" API instead. // produces: // - application/json // parameters: @@ -990,7 +943,7 @@ func GetFileContentsGet(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/file-contents repository repoGetFileContents // --- // summary: Get the metadata and contents of requested files - // description: See the POST method. This GET method supports to use JSON encoded request body in query parameter. + // description: See the POST method. This GET method supports using JSON encoded request body in query parameter. // produces: // - application/json // parameters: @@ -1020,7 +973,7 @@ func GetFileContentsGet(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" - // POST method requires "write" permission, so we also support this "GET" method + // The POST method requires "write" permission, so we also support this "GET" method handleGetFileContents(ctx) } @@ -1064,7 +1017,7 @@ func GetFileContentsPost(ctx *context.APIContext) { // This is actually a "read" request, but we need to accept a "files" list, then POST method seems easy to use. // But the permission system requires that the caller must have "write" permission to use POST method. - // At the moment there is no other way to get around the permission check, so there is a "GET" workaround method above. + // At the moment, there is no other way to get around the permission check, so there is a "GET" workaround method above. handleGetFileContents(ctx) } @@ -1081,6 +1034,6 @@ func handleGetFileContents(ctx *context.APIContext) { if ctx.Written() { return } - filesResponse := files_service.GetContentsListFromTreePaths(ctx, ctx.Repo.Repository, refCommit, opts.Files) + filesResponse := files_service.GetContentsListFromTreePaths(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, refCommit, opts.Files) ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(filesResponse)) } diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index e678db5262..d4a5872fd1 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -152,7 +152,7 @@ func SearchIssues(ctx *context.APIContext) { ) { // find repos user can access (for issue search) - opts := &repo_model.SearchRepoOptions{ + opts := repo_model.SearchRepoOptions{ Private: false, AllPublic: true, TopicOnly: false, @@ -895,6 +895,15 @@ func EditIssue(ctx *context.APIContext) { issue.MilestoneID != *form.Milestone { oldMilestoneID := issue.MilestoneID issue.MilestoneID = *form.Milestone + if issue.MilestoneID > 0 { + issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, *form.Milestone) + if err != nil { + ctx.APIErrorInternal(err) + return + } + } else { + issue.Milestone = nil + } if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 0c572a06a8..cc342a9313 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -609,15 +609,17 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) return } - oldContent := comment.Content - comment.Content = form.Body - if err := issue_service.UpdateComment(ctx, comment, comment.ContentVersion, ctx.Doer, oldContent); err != nil { - if errors.Is(err, user_model.ErrBlockedUser) { - ctx.APIError(http.StatusForbidden, err) - } else { - ctx.APIErrorInternal(err) + if form.Body != comment.Content { + oldContent := comment.Content + comment.Content = form.Body + if err := issue_service.UpdateComment(ctx, comment, comment.ContentVersion, ctx.Doer, oldContent); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.APIError(http.StatusForbidden, err) + } else { + ctx.APIErrorInternal(err) + } + return } - return } ctx.JSON(http.StatusOK, convert.ToAPIComment(ctx, ctx.Repo.Repository, comment)) diff --git a/routers/api/v1/repo/issue_dependency.go b/routers/api/v1/repo/issue_dependency.go index 2048c76ea0..1b58beb7b6 100644 --- a/routers/api/v1/repo/issue_dependency.go +++ b/routers/api/v1/repo/issue_dependency.go @@ -77,10 +77,7 @@ func GetIssueDependencies(ctx *context.APIContext) { return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) limit := ctx.FormInt("limit") if limit == 0 { limit = setting.API.DefaultPagingNum @@ -328,10 +325,7 @@ func GetIssueBlocks(ctx *context.APIContext) { return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) limit := ctx.FormInt("limit") if limit <= 1 { limit = setting.API.DefaultPagingNum diff --git a/routers/api/v1/repo/issue_stopwatch.go b/routers/api/v1/repo/issue_stopwatch.go index b18e172b37..0f28b9757d 100644 --- a/routers/api/v1/repo/issue_stopwatch.go +++ b/routers/api/v1/repo/issue_stopwatch.go @@ -4,7 +4,6 @@ package repo import ( - "errors" "net/http" issues_model "code.gitea.io/gitea/models/issues" @@ -49,14 +48,17 @@ func StartIssueStopwatch(ctx *context.APIContext) { // "409": // description: Cannot start a stopwatch again if it already exists - issue, err := prepareIssueStopwatch(ctx, false) - if err != nil { + issue := prepareIssueForStopwatch(ctx) + if ctx.Written() { return } - if err := issues_model.CreateIssueStopwatch(ctx, ctx.Doer, issue); err != nil { + if ok, err := issues_model.CreateIssueStopwatch(ctx, ctx.Doer, issue); err != nil { ctx.APIErrorInternal(err) return + } else if !ok { + ctx.APIError(http.StatusConflict, "cannot start a stopwatch again if it already exists") + return } ctx.Status(http.StatusCreated) @@ -96,18 +98,20 @@ func StopIssueStopwatch(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" // "409": - // description: Cannot stop a non existent stopwatch + // description: Cannot stop a non-existent stopwatch - issue, err := prepareIssueStopwatch(ctx, true) - if err != nil { + issue := prepareIssueForStopwatch(ctx) + if ctx.Written() { return } - if err := issues_model.FinishIssueStopwatch(ctx, ctx.Doer, issue); err != nil { + if ok, err := issues_model.FinishIssueStopwatch(ctx, ctx.Doer, issue); err != nil { ctx.APIErrorInternal(err) return + } else if !ok { + ctx.APIError(http.StatusConflict, "cannot stop a non-existent stopwatch") + return } - ctx.Status(http.StatusCreated) } @@ -145,22 +149,25 @@ func DeleteIssueStopwatch(ctx *context.APIContext) { // "404": // "$ref": "#/responses/notFound" // "409": - // description: Cannot cancel a non existent stopwatch + // description: Cannot cancel a non-existent stopwatch - issue, err := prepareIssueStopwatch(ctx, true) - if err != nil { + issue := prepareIssueForStopwatch(ctx) + if ctx.Written() { return } - if err := issues_model.CancelStopwatch(ctx, ctx.Doer, issue); err != nil { + if ok, err := issues_model.CancelStopwatch(ctx, ctx.Doer, issue); err != nil { ctx.APIErrorInternal(err) return + } else if !ok { + ctx.APIError(http.StatusConflict, "cannot cancel a non-existent stopwatch") + return } ctx.Status(http.StatusNoContent) } -func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*issues_model.Issue, error) { +func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue { issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) if err != nil { if issues_model.IsErrIssueNotExist(err) { @@ -168,32 +175,19 @@ func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*issues_m } else { ctx.APIErrorInternal(err) } - - return nil, err + return nil } if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { ctx.Status(http.StatusForbidden) - return nil, errors.New("Unable to write to PRs") + return nil } if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { ctx.Status(http.StatusForbidden) - return nil, errors.New("Cannot use time tracker") - } - - if issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID) != shouldExist { - if shouldExist { - ctx.APIError(http.StatusConflict, "cannot stop/cancel a non existent stopwatch") - err = errors.New("cannot stop/cancel a non existent stopwatch") - } else { - ctx.APIError(http.StatusConflict, "cannot start a stopwatch again if it already exists") - err = errors.New("cannot start a stopwatch again if it already exists") - } - return nil, err + return nil } - - return issue, nil + return issue } // GetStopwatches get all stopwatches diff --git a/routers/api/v1/repo/issue_subscription.go b/routers/api/v1/repo/issue_subscription.go index 21e549496d..c89f228a06 100644 --- a/routers/api/v1/repo/issue_subscription.go +++ b/routers/api/v1/repo/issue_subscription.go @@ -43,7 +43,7 @@ func AddIssueSubscription(ctx *context.APIContext) { // required: true // - name: user // in: path - // description: user to subscribe + // description: username of the user to subscribe the issue to // type: string // required: true // responses: @@ -87,7 +87,7 @@ func DelIssueSubscription(ctx *context.APIContext) { // required: true // - name: user // in: path - // description: user witch unsubscribe + // description: username of the user to unsubscribe from an issue // type: string // required: true // responses: diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index dd6abf94c6..171da272cc 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -405,7 +405,7 @@ func ListTrackedTimesByUser(ctx *context.APIContext) { // required: true // - name: user // in: path - // description: username of user + // description: username of the user whose tracked times are to be listed // type: string // required: true // responses: diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go index f2e0cad86c..c1e0b47d33 100644 --- a/routers/api/v1/repo/migrate.go +++ b/routers/api/v1/repo/migrate.go @@ -203,7 +203,7 @@ func Migrate(ctx *context.APIContext) { } if repo != nil { - if errDelete := repo_service.DeleteRepositoryDirectly(ctx, ctx.Doer, repo.ID); errDelete != nil { + if errDelete := repo_service.DeleteRepositoryDirectly(ctx, repo.ID); errDelete != nil { log.Error("DeleteRepository: %v", errDelete) } } diff --git a/routers/api/v1/repo/patch.go b/routers/api/v1/repo/patch.go index bcf498bf7e..e9f5cf5d90 100644 --- a/routers/api/v1/repo/patch.go +++ b/routers/api/v1/repo/patch.go @@ -5,15 +5,10 @@ package repo import ( "net/http" - "time" - git_model "code.gitea.io/gitea/models/git" - repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" - "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" - pull_service "code.gitea.io/gitea/services/pull" "code.gitea.io/gitea/services/repository/files" ) @@ -49,63 +44,22 @@ func ApplyDiffPatch(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "423": // "$ref": "#/responses/repoArchivedError" - apiOpts := web.GetForm(ctx).(*api.ApplyDiffPatchFileOptions) - + apiOpts, changeRepoFileOpts := getAPIChangeRepoFileOptions[*api.ApplyDiffPatchFileOptions](ctx) opts := &files.ApplyDiffPatchOptions{ - Content: apiOpts.Content, - SHA: apiOpts.SHA, - Message: apiOpts.Message, - OldBranch: apiOpts.BranchName, - NewBranch: apiOpts.NewBranchName, - Committer: &files.IdentityOptions{ - GitUserName: apiOpts.Committer.Name, - GitUserEmail: apiOpts.Committer.Email, - }, - Author: &files.IdentityOptions{ - GitUserName: apiOpts.Author.Name, - GitUserEmail: apiOpts.Author.Email, - }, - Dates: &files.CommitDateOptions{ - Author: apiOpts.Dates.Author, - Committer: apiOpts.Dates.Committer, - }, - Signoff: apiOpts.Signoff, - } - if opts.Dates.Author.IsZero() { - opts.Dates.Author = time.Now() - } - if opts.Dates.Committer.IsZero() { - opts.Dates.Committer = time.Now() - } - - if opts.Message == "" { - opts.Message = "apply-patch" - } + Content: apiOpts.Content, + Message: util.IfZero(apiOpts.Message, "apply-patch"), - if !canWriteFiles(ctx, apiOpts.BranchName) { - ctx.APIErrorInternal(repo_model.ErrUserDoesNotHaveAccessToRepo{ - UserID: ctx.Doer.ID, - RepoName: ctx.Repo.Repository.LowerName, - }) - return + OldBranch: changeRepoFileOpts.OldBranch, + NewBranch: changeRepoFileOpts.NewBranch, + Committer: changeRepoFileOpts.Committer, + Author: changeRepoFileOpts.Author, + Dates: changeRepoFileOpts.Dates, + Signoff: changeRepoFileOpts.Signoff, } fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts) if err != nil { - if files.IsErrUserCannotCommit(err) || pull_service.IsErrFilePathProtected(err) { - ctx.APIError(http.StatusForbidden, err) - return - } - if git_model.IsErrBranchAlreadyExists(err) || files.IsErrFilenameInvalid(err) || pull_service.IsErrSHADoesNotMatch(err) || - files.IsErrFilePathInvalid(err) || files.IsErrRepoFileAlreadyExists(err) { - ctx.APIError(http.StatusUnprocessableEntity, err) - return - } - if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) { - ctx.APIError(http.StatusNotFound, err) - return - } - ctx.APIErrorInternal(err) + handleChangeRepoFilesError(ctx, err) } else { ctx.JSON(http.StatusCreated, fileResponse) } diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index c0ab381bc8..2c194f9253 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -73,7 +74,7 @@ func ListPullRequests(ctx *context.APIContext) { // in: query // description: Type of sort // type: string - // enum: [oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority] + // enum: [oldest, recentupdate, recentclose, leastupdate, mostcomment, leastcomment, priority] // - name: milestone // in: query // description: ID of the milestone @@ -706,6 +707,11 @@ func EditPullRequest(ctx *context.APIContext) { issue.MilestoneID != form.Milestone { oldMilestoneID := issue.MilestoneID issue.MilestoneID = form.Milestone + issue.Milestone, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, form.Milestone) + if err != nil { + ctx.APIErrorInternal(err) + return + } if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { ctx.APIErrorInternal(err) return @@ -1296,7 +1302,7 @@ func UpdatePullRequest(ctx *context.APIContext) { // default merge commit message message := fmt.Sprintf("Merge branch '%s' into %s", pr.BaseBranch, pr.HeadBranch) - if err = pull_service.Update(ctx, pr, ctx.Doer, message, rebase); err != nil { + if err = pull_service.Update(graceful.GetManager().ShutdownContext(), pr, ctx.Doer, message, rebase); err != nil { if pull_service.IsErrMergeConflicts(err) { ctx.APIError(http.StatusConflict, "merge failed because of conflict") return diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index b6f5a3ac9e..272b395dfb 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -247,7 +247,9 @@ func CreateRelease(ctx *context.APIContext) { IsTag: false, Repo: ctx.Repo.Repository, } - if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, nil, ""); err != nil { + // GitHub doesn't have "tag_message", GitLab has: https://docs.gitlab.com/api/releases/#create-a-release + // It doesn't need to be the same as the "release note" + if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, nil, form.TagMessage); err != nil { if repo_model.IsErrReleaseAlreadyExist(err) { ctx.APIError(http.StatusConflict, err) } else if release_service.IsErrProtectedTagName(err) { diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 7caf004b4a..8acc912796 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -134,7 +134,7 @@ func Search(ctx *context.APIContext) { private = false } - opts := &repo_model.SearchRepoOptions{ + opts := repo_model.SearchRepoOptions{ ListOptions: utils.GetListOptions(ctx), Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), diff --git a/routers/api/v1/repo/status.go b/routers/api/v1/repo/status.go index 756adcf3a3..40007ea1e5 100644 --- a/routers/api/v1/repo/status.go +++ b/routers/api/v1/repo/status.go @@ -258,19 +258,24 @@ func GetCombinedCommitStatusByRef(ctx *context.APIContext) { repo := ctx.Repo.Repository - statuses, count, err := git_model.GetLatestCommitStatus(ctx, repo.ID, refCommit.Commit.ID.String(), utils.GetListOptions(ctx)) + statuses, err := git_model.GetLatestCommitStatus(ctx, repo.ID, refCommit.Commit.ID.String(), utils.GetListOptions(ctx)) if err != nil { ctx.APIErrorInternal(fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), refCommit.CommitID, err)) return } + count, err := git_model.CountLatestCommitStatus(ctx, repo.ID, refCommit.Commit.ID.String()) + if err != nil { + ctx.APIErrorInternal(fmt.Errorf("CountLatestCommitStatus[%s, %s]: %w", repo.FullName(), refCommit.CommitID, err)) + return + } + ctx.SetTotalCountHeader(count) + if len(statuses) == 0 { ctx.JSON(http.StatusOK, &api.CombinedStatus{}) return } combiStatus := convert.ToCombinedStatus(ctx, statuses, convert.ToRepo(ctx, repo, ctx.Repo.Permission)) - - ctx.SetTotalCountHeader(count) ctx.JSON(http.StatusOK, combiStatus) } diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go index d5840b4149..8e24ffa465 100644 --- a/routers/api/v1/repo/wiki.go +++ b/routers/api/v1/repo/wiki.go @@ -298,10 +298,7 @@ func ListWikiPages(ctx *context.APIContext) { return } - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) limit := ctx.FormInt("limit") if limit <= 1 { limit = setting.API.DefaultPagingNum @@ -434,10 +431,7 @@ func ListPageRevisions(ctx *context.APIContext) { // get commit count - wiki revisions commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } + page := max(ctx.FormInt("page"), 1) // get Commit Count commitsHistory, err := wikiRepo.CommitsByFileAndRange( @@ -505,7 +499,7 @@ func wikiContentsByEntry(ctx *context.APIContext, entry *git.TreeEntry) string { if blob.Size() > setting.API.DefaultMaxBlobSize { return "" } - content, err := blob.GetBlobContentBase64() + content, err := blob.GetBlobContentBase64(nil) if err != nil { ctx.APIErrorInternal(err) return "" diff --git a/routers/api/v1/shared/action.go b/routers/api/v1/shared/action.go new file mode 100644 index 0000000000..c97e9419fd --- /dev/null +++ b/routers/api/v1/shared/action.go @@ -0,0 +1,187 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "fmt" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListJobs lists jobs for api route validated ownerID and repoID +// ownerID == 0 and repoID == 0 means all jobs +// ownerID == 0 and repoID != 0 means all jobs for the given repo +// ownerID != 0 and repoID == 0 means all jobs for the given user/org +// ownerID != 0 and repoID != 0 undefined behavior +// runID == 0 means all jobs +// runID is used as an additional filter together with ownerID and repoID to only return jobs for the given run +// Access rights are checked at the API route level +func ListJobs(ctx *context.APIContext, ownerID, repoID, runID int64) { + if ownerID != 0 && repoID != 0 { + setting.PanicInDevOrTesting("ownerID and repoID should not be both set") + } + opts := actions_model.FindRunJobOptions{ + OwnerID: ownerID, + RepoID: repoID, + RunID: runID, + ListOptions: utils.GetListOptions(ctx), + } + for _, status := range ctx.FormStrings("status") { + values, err := convertToInternal(status) + if err != nil { + ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status)) + return + } + opts.Statuses = append(opts.Statuses, values...) + } + + jobs, total, err := db.FindAndCount[actions_model.ActionRunJob](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + res := new(api.ActionWorkflowJobsResponse) + res.TotalCount = total + + res.Entries = make([]*api.ActionWorkflowJob, len(jobs)) + + isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID + for i := range jobs { + var repository *repo_model.Repository + if isRepoLevel { + repository = ctx.Repo.Repository + } else { + repository, err = repo_model.GetRepositoryByID(ctx, jobs[i].RepoID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + } + + convertedWorkflowJob, err := convert.ToActionWorkflowJob(ctx, repository, nil, jobs[i]) + if err != nil { + ctx.APIErrorInternal(err) + return + } + res.Entries[i] = convertedWorkflowJob + } + + ctx.JSON(http.StatusOK, &res) +} + +func convertToInternal(s string) ([]actions_model.Status, error) { + switch s { + case "pending", "waiting", "requested", "action_required": + return []actions_model.Status{actions_model.StatusBlocked}, nil + case "queued": + return []actions_model.Status{actions_model.StatusWaiting}, nil + case "in_progress": + return []actions_model.Status{actions_model.StatusRunning}, nil + case "completed": + return []actions_model.Status{ + actions_model.StatusSuccess, + actions_model.StatusFailure, + actions_model.StatusSkipped, + actions_model.StatusCancelled, + }, nil + case "failure": + return []actions_model.Status{actions_model.StatusFailure}, nil + case "success": + return []actions_model.Status{actions_model.StatusSuccess}, nil + case "skipped", "neutral": + return []actions_model.Status{actions_model.StatusSkipped}, nil + case "cancelled", "timed_out": + return []actions_model.Status{actions_model.StatusCancelled}, nil + default: + return nil, fmt.Errorf("invalid status %s", s) + } +} + +// ListRuns lists jobs for api route validated ownerID and repoID +// ownerID == 0 and repoID == 0 means all runs +// ownerID == 0 and repoID != 0 means all runs for the given repo +// ownerID != 0 and repoID == 0 means all runs for the given user/org +// ownerID != 0 and repoID != 0 undefined behavior +// Access rights are checked at the API route level +func ListRuns(ctx *context.APIContext, ownerID, repoID int64) { + if ownerID != 0 && repoID != 0 { + setting.PanicInDevOrTesting("ownerID and repoID should not be both set") + } + opts := actions_model.FindRunOptions{ + OwnerID: ownerID, + RepoID: repoID, + ListOptions: utils.GetListOptions(ctx), + } + + if event := ctx.FormString("event"); event != "" { + opts.TriggerEvent = webhook.HookEventType(event) + } + if branch := ctx.FormString("branch"); branch != "" { + opts.Ref = string(git.RefNameFromBranch(branch)) + } + for _, status := range ctx.FormStrings("status") { + values, err := convertToInternal(status) + if err != nil { + ctx.APIError(http.StatusBadRequest, fmt.Errorf("Invalid status %s", status)) + return + } + opts.Status = append(opts.Status, values...) + } + if actor := ctx.FormString("actor"); actor != "" { + user, err := user_model.GetUserByName(ctx, actor) + if err != nil { + ctx.APIErrorInternal(err) + return + } + opts.TriggerUserID = user.ID + } + if headSHA := ctx.FormString("head_sha"); headSHA != "" { + opts.CommitSHA = headSHA + } + + runs, total, err := db.FindAndCount[actions_model.ActionRun](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + res := new(api.ActionWorkflowRunsResponse) + res.TotalCount = total + + res.Entries = make([]*api.ActionWorkflowRun, len(runs)) + isRepoLevel := repoID != 0 && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == repoID + for i := range runs { + var repository *repo_model.Repository + if isRepoLevel { + repository = ctx.Repo.Repository + } else { + repository, err = repo_model.GetRepositoryByID(ctx, runs[i].RepoID) + if err != nil { + ctx.APIErrorInternal(err) + return + } + } + + convertedRun, err := convert.ToActionWorkflowRun(ctx, repository, runs[i]) + if err != nil { + ctx.APIErrorInternal(err) + return + } + res.Entries[i] = convertedRun + } + + ctx.JSON(http.StatusOK, &res) +} diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go index d42f330d1c..e9834aff9f 100644 --- a/routers/api/v1/shared/runners.go +++ b/routers/api/v1/shared/runners.go @@ -67,6 +67,28 @@ func ListRunners(ctx *context.APIContext, ownerID, repoID int64) { ctx.JSON(http.StatusOK, &res) } +func getRunnerByID(ctx *context.APIContext, ownerID, repoID, runnerID int64) (*actions_model.ActionRunner, bool) { + if ownerID != 0 && repoID != 0 { + setting.PanicInDevOrTesting("ownerID and repoID should not be both set") + } + + runner, err := actions_model.GetRunnerByID(ctx, runnerID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.APIErrorNotFound("Runner not found") + } else { + ctx.APIErrorInternal(err) + } + return nil, false + } + + if !runner.EditableInContext(ownerID, repoID) { + ctx.APIErrorNotFound("No permission to access this runner") + return nil, false + } + return runner, true +} + // GetRunner get the runner for api route validated ownerID and repoID // ownerID == 0 and repoID == 0 means any runner including global runners // ownerID == 0 and repoID != 0 means any runner for the given repo @@ -77,13 +99,8 @@ func GetRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) { if ownerID != 0 && repoID != 0 { setting.PanicInDevOrTesting("ownerID and repoID should not be both set") } - runner, err := actions_model.GetRunnerByID(ctx, runnerID) - if err != nil { - ctx.APIErrorNotFound(err) - return - } - if !runner.EditableInContext(ownerID, repoID) { - ctx.APIErrorNotFound("No permission to get this runner") + runner, ok := getRunnerByID(ctx, ownerID, repoID, runnerID) + if !ok { return } ctx.JSON(http.StatusOK, convert.ToActionRunner(ctx, runner)) @@ -96,20 +113,12 @@ func GetRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) { // ownerID != 0 and repoID != 0 undefined behavior // Access rights are checked at the API route level func DeleteRunner(ctx *context.APIContext, ownerID, repoID, runnerID int64) { - if ownerID != 0 && repoID != 0 { - setting.PanicInDevOrTesting("ownerID and repoID should not be both set") - } - runner, err := actions_model.GetRunnerByID(ctx, runnerID) - if err != nil { - ctx.APIErrorInternal(err) - return - } - if !runner.EditableInContext(ownerID, repoID) { - ctx.APIErrorNotFound("No permission to delete this runner") + runner, ok := getRunnerByID(ctx, ownerID, repoID, runnerID) + if !ok { return } - err = actions_model.DeleteRunner(ctx, runner.ID) + err := actions_model.DeleteRunner(ctx, runner.ID) if err != nil { ctx.APIErrorInternal(err) return diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go index df0c8a805a..9e20c0533b 100644 --- a/routers/api/v1/swagger/repo.go +++ b/routers/api/v1/swagger/repo.go @@ -331,6 +331,12 @@ type swaggerContentsListResponse struct { Body []api.ContentsResponse `json:"body"` } +// swagger:response ContentsExtResponse +type swaggerContentsExtResponse struct { + // in:body + Body api.ContentsExtResponse `json:"body"` +} + // FileDeleteResponse // swagger:response FileDeleteResponse type swaggerFileDeleteResponse struct { @@ -443,6 +449,34 @@ type swaggerRepoTasksList struct { Body api.ActionTaskResponse `json:"body"` } +// WorkflowRunsList +// swagger:response WorkflowRunsList +type swaggerActionWorkflowRunsResponse struct { + // in:body + Body api.ActionWorkflowRunsResponse `json:"body"` +} + +// WorkflowRun +// swagger:response WorkflowRun +type swaggerWorkflowRun struct { + // in:body + Body api.ActionWorkflowRun `json:"body"` +} + +// WorkflowJobsList +// swagger:response WorkflowJobsList +type swaggerActionWorkflowJobsResponse struct { + // in:body + Body api.ActionWorkflowJobsResponse `json:"body"` +} + +// WorkflowJob +// swagger:response WorkflowJob +type swaggerWorkflowJob struct { + // in:body + Body api.ActionWorkflowJob `json:"body"` +} + // ArtifactsList // swagger:response ArtifactsList type swaggerRepoArtifactsList struct { diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go index 04097fcc95..e934d02aa7 100644 --- a/routers/api/v1/user/action.go +++ b/routers/api/v1/user/action.go @@ -12,6 +12,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/shared" "code.gitea.io/gitea/routers/api/v1/utils" actions_service "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/context" @@ -127,13 +128,11 @@ func CreateVariable(ctx *context.APIContext) { // "$ref": "#/definitions/CreateVariableOption" // responses: // "201": - // description: response when creating a variable - // "204": - // description: response when creating a variable + // description: successfully created the user-level variable // "400": // "$ref": "#/responses/error" - // "404": - // "$ref": "#/responses/notFound" + // "409": + // description: variable name already exists. opt := web.GetForm(ctx).(*api.CreateVariableOption) @@ -162,7 +161,7 @@ func CreateVariable(ctx *context.APIContext) { return } - ctx.Status(http.StatusNoContent) + ctx.Status(http.StatusCreated) } // UpdateVariable update a user-level variable which is created by current doer @@ -358,3 +357,86 @@ func ListVariables(ctx *context.APIContext) { ctx.SetTotalCountHeader(count) ctx.JSON(http.StatusOK, variables) } + +// ListWorkflowRuns lists workflow runs +func ListWorkflowRuns(ctx *context.APIContext) { + // swagger:operation GET /user/actions/runs user getUserWorkflowRuns + // --- + // summary: Get workflow runs + // parameters: + // - name: event + // in: query + // description: workflow event name + // type: string + // required: false + // - name: branch + // in: query + // description: workflow branch + // type: string + // required: false + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: actor + // in: query + // description: triggered by user + // type: string + // required: false + // - name: head_sha + // in: query + // description: triggering sha of the workflow run + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/WorkflowRunsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + shared.ListRuns(ctx, ctx.Doer.ID, 0) +} + +// ListWorkflowJobs lists workflow jobs +func ListWorkflowJobs(ctx *context.APIContext) { + // swagger:operation GET /user/actions/jobs user getUserWorkflowJobs + // --- + // summary: Get workflow jobs + // parameters: + // - name: status + // in: query + // description: workflow status (pending, queued, in_progress, failure, success, skipped) + // type: string + // required: false + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/WorkflowJobsList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + shared.ListJobs(ctx, ctx.Doer.ID, 0, 0) +} diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go index 7201010161..6f1053e7ac 100644 --- a/routers/api/v1/user/app.go +++ b/routers/api/v1/user/app.go @@ -30,7 +30,7 @@ func ListAccessTokens(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of to user whose access tokens are to be listed // type: string // required: true // - name: page @@ -83,7 +83,7 @@ func CreateAccessToken(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose token is to be created // required: true // type: string // - name: body @@ -149,7 +149,7 @@ func DeleteAccessToken(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose token is to be deleted // type: string // required: true // - name: token diff --git a/routers/api/v1/user/block.go b/routers/api/v1/user/block.go index 7231e9add7..8365188f60 100644 --- a/routers/api/v1/user/block.go +++ b/routers/api/v1/user/block.go @@ -37,7 +37,7 @@ func CheckUserBlock(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: user to check + // description: username of the user to check // type: string // required: true // responses: @@ -56,7 +56,7 @@ func BlockUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: user to block + // description: username of the user to block // type: string // required: true // - name: note @@ -81,7 +81,7 @@ func UnblockUser(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: user to unblock + // description: username of the user to unblock // type: string // required: true // responses: diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index 0d0c0be7e0..339b994af4 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -67,7 +67,7 @@ func ListFollowers(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose followers are to be listed // type: string // required: true // - name: page @@ -131,7 +131,7 @@ func ListFollowing(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose followed users are to be listed // type: string // required: true // - name: page @@ -167,7 +167,7 @@ func CheckMyFollowing(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of followed user + // description: username of the user to check for authenticated followers // type: string // required: true // responses: @@ -187,12 +187,12 @@ func CheckFollowing(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of following user + // description: username of the following user // type: string // required: true // - name: target // in: path - // description: username of followed user + // description: username of the followed user // type: string // required: true // responses: @@ -216,7 +216,7 @@ func Follow(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to follow + // description: username of the user to follow // type: string // required: true // responses: @@ -246,7 +246,7 @@ func Unfollow(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to unfollow + // description: username of the user to unfollow // type: string // required: true // responses: diff --git a/routers/api/v1/user/gpg_key.go b/routers/api/v1/user/gpg_key.go index b76bd8a1ee..9ec4d2c938 100644 --- a/routers/api/v1/user/gpg_key.go +++ b/routers/api/v1/user/gpg_key.go @@ -53,7 +53,7 @@ func ListGPGKeys(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose GPG key list is to be obtained // type: string // required: true // - name: page diff --git a/routers/api/v1/user/key.go b/routers/api/v1/user/key.go index 628f5d6cac..aa69245e49 100644 --- a/routers/api/v1/user/key.go +++ b/routers/api/v1/user/key.go @@ -136,7 +136,7 @@ func ListPublicKeys(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose public keys are to be listed // type: string // required: true // - name: fingerprint diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go index 6aabc4fb90..6d0129681e 100644 --- a/routers/api/v1/user/repo.go +++ b/routers/api/v1/user/repo.go @@ -19,7 +19,7 @@ import ( func listUserRepos(ctx *context.APIContext, u *user_model.User, private bool) { opts := utils.GetListOptions(ctx) - repos, count, err := repo_model.GetUserRepositories(ctx, &repo_model.SearchRepoOptions{ + repos, count, err := repo_model.GetUserRepositories(ctx, repo_model.SearchRepoOptions{ Actor: u, Private: private, ListOptions: opts, @@ -62,7 +62,7 @@ func ListUserRepos(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose owned repos are to be listed // type: string // required: true // - name: page @@ -103,7 +103,7 @@ func ListMyRepos(ctx *context.APIContext) { // "200": // "$ref": "#/responses/RepositoryList" - opts := &repo_model.SearchRepoOptions{ + opts := repo_model.SearchRepoOptions{ ListOptions: utils.GetListOptions(ctx), Actor: ctx.Doer, OwnerID: ctx.Doer.ID, diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index 4b0cb45d67..ee5d63063b 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -50,7 +50,7 @@ func GetStarredRepos(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose starred repos are to be listed // type: string // required: true // - name: page diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 757a548518..6de1125c40 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -73,7 +73,7 @@ func Search(ctx *context.APIContext) { if ctx.PublicOnly { visible = []structs.VisibleType{structs.VisibleTypePublic} } - users, maxResults, err = user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + users, maxResults, err = user_model.SearchUsers(ctx, user_model.SearchUserOptions{ Actor: ctx.Doer, Keyword: ctx.FormTrim("q"), UID: uid, @@ -110,7 +110,7 @@ func GetInfo(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to get + // description: username of the user whose data is to be listed // type: string // required: true // responses: @@ -151,7 +151,7 @@ func GetUserHeatmapData(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user to get + // description: username of the user whose heatmap is to be obtained // type: string // required: true // responses: @@ -177,7 +177,7 @@ func ListUserActivityFeeds(ctx *context.APIContext) { // parameters: // - name: username // in: path - // description: username of user + // description: username of the user whose activity feeds are to be listed // type: string // required: true // - name: only-performed-by diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 76d7c81793..844eac2c67 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -49,7 +49,7 @@ func GetWatchedRepos(ctx *context.APIContext) { // - name: username // type: string // in: path - // description: username of the user + // description: username of the user whose watched repos are to be listed // required: true // - name: page // in: query diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index 532d157e35..6f598f14c8 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/validation" webhook_module "code.gitea.io/gitea/modules/webhook" "code.gitea.io/gitea/services/context" webhook_service "code.gitea.io/gitea/services/webhook" @@ -92,6 +93,10 @@ func checkCreateHookOption(ctx *context.APIContext, form *api.CreateHookOption) ctx.APIError(http.StatusUnprocessableEntity, "Invalid content type") return false } + if !validation.IsValidURL(form.Config["url"]) { + ctx.APIError(http.StatusUnprocessableEntity, "Invalid url") + return false + } return true } @@ -154,6 +159,42 @@ func pullHook(events []string, event string) bool { return util.SliceContainsString(events, event, true) || util.SliceContainsString(events, string(webhook_module.HookEventPullRequest), true) } +func updateHookEvents(events []string) webhook_module.HookEvents { + if len(events) == 0 { + events = []string{"push"} + } + hookEvents := make(webhook_module.HookEvents) + hookEvents[webhook_module.HookEventCreate] = util.SliceContainsString(events, string(webhook_module.HookEventCreate), true) + hookEvents[webhook_module.HookEventPush] = util.SliceContainsString(events, string(webhook_module.HookEventPush), true) + hookEvents[webhook_module.HookEventDelete] = util.SliceContainsString(events, string(webhook_module.HookEventDelete), true) + hookEvents[webhook_module.HookEventFork] = util.SliceContainsString(events, string(webhook_module.HookEventFork), true) + hookEvents[webhook_module.HookEventRepository] = util.SliceContainsString(events, string(webhook_module.HookEventRepository), true) + hookEvents[webhook_module.HookEventWiki] = util.SliceContainsString(events, string(webhook_module.HookEventWiki), true) + hookEvents[webhook_module.HookEventRelease] = util.SliceContainsString(events, string(webhook_module.HookEventRelease), true) + hookEvents[webhook_module.HookEventPackage] = util.SliceContainsString(events, string(webhook_module.HookEventPackage), true) + hookEvents[webhook_module.HookEventStatus] = util.SliceContainsString(events, string(webhook_module.HookEventStatus), true) + hookEvents[webhook_module.HookEventWorkflowRun] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowRun), true) + hookEvents[webhook_module.HookEventWorkflowJob] = util.SliceContainsString(events, string(webhook_module.HookEventWorkflowJob), true) + + // Issues + hookEvents[webhook_module.HookEventIssues] = issuesHook(events, "issues_only") + hookEvents[webhook_module.HookEventIssueAssign] = issuesHook(events, string(webhook_module.HookEventIssueAssign)) + hookEvents[webhook_module.HookEventIssueLabel] = issuesHook(events, string(webhook_module.HookEventIssueLabel)) + hookEvents[webhook_module.HookEventIssueMilestone] = issuesHook(events, string(webhook_module.HookEventIssueMilestone)) + hookEvents[webhook_module.HookEventIssueComment] = issuesHook(events, string(webhook_module.HookEventIssueComment)) + + // Pull requests + hookEvents[webhook_module.HookEventPullRequest] = pullHook(events, "pull_request_only") + hookEvents[webhook_module.HookEventPullRequestAssign] = pullHook(events, string(webhook_module.HookEventPullRequestAssign)) + hookEvents[webhook_module.HookEventPullRequestLabel] = pullHook(events, string(webhook_module.HookEventPullRequestLabel)) + hookEvents[webhook_module.HookEventPullRequestMilestone] = pullHook(events, string(webhook_module.HookEventPullRequestMilestone)) + hookEvents[webhook_module.HookEventPullRequestComment] = pullHook(events, string(webhook_module.HookEventPullRequestComment)) + hookEvents[webhook_module.HookEventPullRequestReview] = pullHook(events, "pull_request_review") + hookEvents[webhook_module.HookEventPullRequestReviewRequest] = pullHook(events, string(webhook_module.HookEventPullRequestReviewRequest)) + hookEvents[webhook_module.HookEventPullRequestSync] = pullHook(events, string(webhook_module.HookEventPullRequestSync)) + return hookEvents +} + // addHook add the hook specified by `form`, `ownerID` and `repoID`. If there is // an error, write to `ctx` accordingly. Return (webhook, ok) func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoID int64) (*webhook.Webhook, bool) { @@ -162,9 +203,6 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI return nil, false } - if len(form.Events) == 0 { - form.Events = []string{"push"} - } if form.Config["is_system_webhook"] != "" { sw, err := strconv.ParseBool(form.Config["is_system_webhook"]) if err != nil { @@ -183,31 +221,7 @@ func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoI IsSystemWebhook: isSystemWebhook, HookEvent: &webhook_module.HookEvent{ ChooseEvents: true, - HookEvents: webhook_module.HookEvents{ - webhook_module.HookEventCreate: util.SliceContainsString(form.Events, string(webhook_module.HookEventCreate), true), - webhook_module.HookEventDelete: util.SliceContainsString(form.Events, string(webhook_module.HookEventDelete), true), - webhook_module.HookEventFork: util.SliceContainsString(form.Events, string(webhook_module.HookEventFork), true), - webhook_module.HookEventIssues: issuesHook(form.Events, "issues_only"), - webhook_module.HookEventIssueAssign: issuesHook(form.Events, string(webhook_module.HookEventIssueAssign)), - webhook_module.HookEventIssueLabel: issuesHook(form.Events, string(webhook_module.HookEventIssueLabel)), - webhook_module.HookEventIssueMilestone: issuesHook(form.Events, string(webhook_module.HookEventIssueMilestone)), - webhook_module.HookEventIssueComment: issuesHook(form.Events, string(webhook_module.HookEventIssueComment)), - webhook_module.HookEventPush: util.SliceContainsString(form.Events, string(webhook_module.HookEventPush), true), - webhook_module.HookEventPullRequest: pullHook(form.Events, "pull_request_only"), - webhook_module.HookEventPullRequestAssign: pullHook(form.Events, string(webhook_module.HookEventPullRequestAssign)), - webhook_module.HookEventPullRequestLabel: pullHook(form.Events, string(webhook_module.HookEventPullRequestLabel)), - webhook_module.HookEventPullRequestMilestone: pullHook(form.Events, string(webhook_module.HookEventPullRequestMilestone)), - webhook_module.HookEventPullRequestComment: pullHook(form.Events, string(webhook_module.HookEventPullRequestComment)), - webhook_module.HookEventPullRequestReview: pullHook(form.Events, "pull_request_review"), - webhook_module.HookEventPullRequestReviewRequest: pullHook(form.Events, string(webhook_module.HookEventPullRequestReviewRequest)), - webhook_module.HookEventPullRequestSync: pullHook(form.Events, string(webhook_module.HookEventPullRequestSync)), - webhook_module.HookEventWiki: util.SliceContainsString(form.Events, string(webhook_module.HookEventWiki), true), - webhook_module.HookEventRepository: util.SliceContainsString(form.Events, string(webhook_module.HookEventRepository), true), - webhook_module.HookEventRelease: util.SliceContainsString(form.Events, string(webhook_module.HookEventRelease), true), - webhook_module.HookEventPackage: util.SliceContainsString(form.Events, string(webhook_module.HookEventPackage), true), - webhook_module.HookEventStatus: util.SliceContainsString(form.Events, string(webhook_module.HookEventStatus), true), - webhook_module.HookEventWorkflowJob: util.SliceContainsString(form.Events, string(webhook_module.HookEventWorkflowJob), true), - }, + HookEvents: updateHookEvents(form.Events), BranchFilter: form.BranchFilter, }, IsActive: form.Active, @@ -324,6 +338,10 @@ func EditRepoHook(ctx *context.APIContext, form *api.EditHookOption, hookID int6 func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webhook) bool { if form.Config != nil { if url, ok := form.Config["url"]; ok { + if !validation.IsValidURL(url) { + ctx.APIError(http.StatusUnprocessableEntity, "Invalid url") + return false + } w.URL = url } if ct, ok := form.Config["content_type"]; ok { @@ -352,19 +370,10 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh } // Update events - if len(form.Events) == 0 { - form.Events = []string{"push"} - } + w.HookEvents = updateHookEvents(form.Events) w.PushOnly = false w.SendEverything = false w.ChooseEvents = true - w.HookEvents[webhook_module.HookEventCreate] = util.SliceContainsString(form.Events, string(webhook_module.HookEventCreate), true) - w.HookEvents[webhook_module.HookEventPush] = util.SliceContainsString(form.Events, string(webhook_module.HookEventPush), true) - w.HookEvents[webhook_module.HookEventDelete] = util.SliceContainsString(form.Events, string(webhook_module.HookEventDelete), true) - w.HookEvents[webhook_module.HookEventFork] = util.SliceContainsString(form.Events, string(webhook_module.HookEventFork), true) - w.HookEvents[webhook_module.HookEventRepository] = util.SliceContainsString(form.Events, string(webhook_module.HookEventRepository), true) - w.HookEvents[webhook_module.HookEventWiki] = util.SliceContainsString(form.Events, string(webhook_module.HookEventWiki), true) - w.HookEvents[webhook_module.HookEventRelease] = util.SliceContainsString(form.Events, string(webhook_module.HookEventRelease), true) w.BranchFilter = form.BranchFilter err := w.SetHeaderAuthorization(form.AuthorizationHeader) @@ -373,23 +382,6 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh return false } - // Issues - w.HookEvents[webhook_module.HookEventIssues] = issuesHook(form.Events, "issues_only") - w.HookEvents[webhook_module.HookEventIssueAssign] = issuesHook(form.Events, string(webhook_module.HookEventIssueAssign)) - w.HookEvents[webhook_module.HookEventIssueLabel] = issuesHook(form.Events, string(webhook_module.HookEventIssueLabel)) - w.HookEvents[webhook_module.HookEventIssueMilestone] = issuesHook(form.Events, string(webhook_module.HookEventIssueMilestone)) - w.HookEvents[webhook_module.HookEventIssueComment] = issuesHook(form.Events, string(webhook_module.HookEventIssueComment)) - - // Pull requests - w.HookEvents[webhook_module.HookEventPullRequest] = pullHook(form.Events, "pull_request_only") - w.HookEvents[webhook_module.HookEventPullRequestAssign] = pullHook(form.Events, string(webhook_module.HookEventPullRequestAssign)) - w.HookEvents[webhook_module.HookEventPullRequestLabel] = pullHook(form.Events, string(webhook_module.HookEventPullRequestLabel)) - w.HookEvents[webhook_module.HookEventPullRequestMilestone] = pullHook(form.Events, string(webhook_module.HookEventPullRequestMilestone)) - w.HookEvents[webhook_module.HookEventPullRequestComment] = pullHook(form.Events, string(webhook_module.HookEventPullRequestComment)) - w.HookEvents[webhook_module.HookEventPullRequestReview] = pullHook(form.Events, "pull_request_review") - w.HookEvents[webhook_module.HookEventPullRequestReviewRequest] = pullHook(form.Events, string(webhook_module.HookEventPullRequestReviewRequest)) - w.HookEvents[webhook_module.HookEventPullRequestSync] = pullHook(form.Events, string(webhook_module.HookEventPullRequestSync)) - if err := w.UpdateEvent(); err != nil { ctx.APIErrorInternal(err) return false diff --git a/routers/api/v1/utils/hook_test.go b/routers/api/v1/utils/hook_test.go new file mode 100644 index 0000000000..e5e8ce07ce --- /dev/null +++ b/routers/api/v1/utils/hook_test.go @@ -0,0 +1,82 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestTestHookValidation(t *testing.T) { + unittest.PrepareTestEnv(t) + + t.Run("Test Validation", func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1/hooks") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + + checkCreateHookOption(ctx, &structs.CreateHookOption{ + Type: "gitea", + Config: map[string]string{ + "content_type": "json", + "url": "https://example.com/webhook", + }, + }) + assert.Equal(t, 0, ctx.Resp.WrittenStatus()) // not written yet + }) + + t.Run("Test Validation with invalid URL", func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1/hooks") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + + checkCreateHookOption(ctx, &structs.CreateHookOption{ + Type: "gitea", + Config: map[string]string{ + "content_type": "json", + "url": "example.com/webhook", + }, + }) + assert.Equal(t, http.StatusUnprocessableEntity, ctx.Resp.WrittenStatus()) + }) + + t.Run("Test Validation with invalid webhook type", func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1/hooks") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + + checkCreateHookOption(ctx, &structs.CreateHookOption{ + Type: "unknown", + Config: map[string]string{ + "content_type": "json", + "url": "example.com/webhook", + }, + }) + assert.Equal(t, http.StatusUnprocessableEntity, ctx.Resp.WrittenStatus()) + }) + + t.Run("Test Validation with empty content type", func(t *testing.T) { + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1/hooks") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + + checkCreateHookOption(ctx, &structs.CreateHookOption{ + Type: "unknown", + Config: map[string]string{ + "url": "https://example.com/webhook", + }, + }) + assert.Equal(t, http.StatusUnprocessableEntity, ctx.Resp.WrittenStatus()) + }) +} diff --git a/routers/api/v1/utils/main_test.go b/routers/api/v1/utils/main_test.go new file mode 100644 index 0000000000..4eace1f369 --- /dev/null +++ b/routers/api/v1/utils/main_test.go @@ -0,0 +1,21 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + SetUp: func() error { + setting.LoadQueueSettings() + return webhook_service.Init() + }, + }) +} |