From dd136858f1ea40ad3c94191d647487fa4f31926c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.0. Signed-off-by: Daniel Baumann --- routers/api/v1/activitypub/actor.go | 83 ++ routers/api/v1/activitypub/person.go | 106 ++ routers/api/v1/activitypub/repository.go | 80 ++ routers/api/v1/activitypub/repository_test.go | 27 + routers/api/v1/activitypub/reqsignature.go | 99 ++ routers/api/v1/activitypub/response.go | 35 + routers/api/v1/admin/adopt.go | 180 +++ routers/api/v1/admin/cron.go | 86 ++ routers/api/v1/admin/email.go | 87 ++ routers/api/v1/admin/hooks.go | 176 +++ routers/api/v1/admin/org.go | 123 ++ routers/api/v1/admin/quota.go | 53 + routers/api/v1/admin/quota_group.go | 436 ++++++ routers/api/v1/admin/quota_rule.go | 219 +++ routers/api/v1/admin/repo.go | 49 + routers/api/v1/admin/runners.go | 26 + routers/api/v1/admin/user.go | 509 +++++++ routers/api/v1/api.go | 1659 +++++++++++++++++++++++ routers/api/v1/misc/gitignore.go | 56 + routers/api/v1/misc/label_templates.go | 60 + routers/api/v1/misc/licenses.go | 76 ++ routers/api/v1/misc/markup.go | 110 ++ routers/api/v1/misc/markup_test.go | 184 +++ routers/api/v1/misc/nodeinfo.go | 80 ++ routers/api/v1/misc/signing.go | 63 + routers/api/v1/misc/version.go | 25 + routers/api/v1/notify/notifications.go | 77 ++ routers/api/v1/notify/repo.go | 227 ++++ routers/api/v1/notify/threads.go | 118 ++ routers/api/v1/notify/user.go | 175 +++ routers/api/v1/org/action.go | 473 +++++++ routers/api/v1/org/avatar.go | 80 ++ routers/api/v1/org/hook.go | 189 +++ routers/api/v1/org/label.go | 258 ++++ routers/api/v1/org/member.go | 325 +++++ routers/api/v1/org/org.go | 555 ++++++++ routers/api/v1/org/quota.go | 155 +++ routers/api/v1/org/team.go | 887 ++++++++++++ routers/api/v1/packages/package.go | 215 +++ routers/api/v1/repo/action.go | 653 +++++++++ routers/api/v1/repo/avatar.go | 88 ++ routers/api/v1/repo/blob.go | 55 + routers/api/v1/repo/branch.go | 1019 ++++++++++++++ routers/api/v1/repo/collaborators.go | 370 +++++ routers/api/v1/repo/commits.go | 376 +++++ routers/api/v1/repo/compare.go | 99 ++ routers/api/v1/repo/file.go | 1014 ++++++++++++++ routers/api/v1/repo/flags.go | 245 ++++ routers/api/v1/repo/fork.go | 167 +++ routers/api/v1/repo/git_hook.go | 196 +++ routers/api/v1/repo/git_ref.go | 107 ++ routers/api/v1/repo/hook.go | 308 +++++ routers/api/v1/repo/hook_test.go | 33 + routers/api/v1/repo/issue.go | 1041 ++++++++++++++ routers/api/v1/repo/issue_attachment.go | 411 ++++++ routers/api/v1/repo/issue_comment.go | 691 ++++++++++ routers/api/v1/repo/issue_comment_attachment.go | 400 ++++++ routers/api/v1/repo/issue_dependency.go | 613 +++++++++ routers/api/v1/repo/issue_label.go | 385 ++++++ routers/api/v1/repo/issue_pin.go | 309 +++++ routers/api/v1/repo/issue_reaction.go | 424 ++++++ routers/api/v1/repo/issue_stopwatch.go | 241 ++++ routers/api/v1/repo/issue_subscription.go | 294 ++++ routers/api/v1/repo/issue_tracked_time.go | 633 +++++++++ routers/api/v1/repo/key.go | 292 ++++ routers/api/v1/repo/label.go | 285 ++++ routers/api/v1/repo/language.go | 81 ++ routers/api/v1/repo/main_test.go | 21 + routers/api/v1/repo/migrate.go | 281 ++++ routers/api/v1/repo/milestone.go | 309 +++++ routers/api/v1/repo/mirror.go | 449 ++++++ routers/api/v1/repo/notes.go | 104 ++ routers/api/v1/repo/patch.go | 114 ++ routers/api/v1/repo/pull.go | 1635 ++++++++++++++++++++++ routers/api/v1/repo/pull_review.go | 1107 +++++++++++++++ routers/api/v1/repo/release.go | 424 ++++++ routers/api/v1/repo/release_attachment.go | 467 +++++++ routers/api/v1/repo/release_tags.go | 125 ++ routers/api/v1/repo/repo.go | 1334 ++++++++++++++++++ routers/api/v1/repo/repo_test.go | 86 ++ routers/api/v1/repo/star.go | 60 + routers/api/v1/repo/status.go | 282 ++++ routers/api/v1/repo/subscriber.go | 60 + routers/api/v1/repo/tag.go | 668 +++++++++ routers/api/v1/repo/teams.go | 235 ++++ routers/api/v1/repo/topic.go | 305 +++++ routers/api/v1/repo/transfer.go | 254 ++++ routers/api/v1/repo/tree.go | 70 + routers/api/v1/repo/wiki.go | 536 ++++++++ routers/api/v1/settings/settings.go | 86 ++ routers/api/v1/shared/quota.go | 102 ++ routers/api/v1/shared/runners.go | 32 + routers/api/v1/swagger/action.go | 34 + routers/api/v1/swagger/activity.go | 15 + routers/api/v1/swagger/activitypub.go | 15 + routers/api/v1/swagger/app.go | 22 + routers/api/v1/swagger/cron.go | 15 + routers/api/v1/swagger/issue.go | 127 ++ routers/api/v1/swagger/key.go | 50 + routers/api/v1/swagger/misc.go | 71 + routers/api/v1/swagger/nodeinfo.go | 15 + routers/api/v1/swagger/notify.go | 29 + routers/api/v1/swagger/options.go | 234 ++++ routers/api/v1/swagger/org.go | 43 + routers/api/v1/swagger/package.go | 29 + routers/api/v1/swagger/quota.go | 64 + routers/api/v1/swagger/repo.go | 450 ++++++ routers/api/v1/swagger/settings.go | 34 + routers/api/v1/swagger/user.go | 50 + routers/api/v1/user/action.go | 353 +++++ routers/api/v1/user/app.go | 410 ++++++ routers/api/v1/user/avatar.go | 65 + routers/api/v1/user/email.go | 132 ++ routers/api/v1/user/follower.go | 263 ++++ routers/api/v1/user/gpg_key.go | 311 +++++ routers/api/v1/user/helper.go | 35 + routers/api/v1/user/hook.go | 159 +++ routers/api/v1/user/key.go | 303 +++++ routers/api/v1/user/quota.go | 118 ++ routers/api/v1/user/repo.go | 186 +++ routers/api/v1/user/runners.go | 26 + routers/api/v1/user/settings.go | 67 + routers/api/v1/user/star.go | 197 +++ routers/api/v1/user/user.go | 306 +++++ routers/api/v1/user/watch.go | 211 +++ routers/api/v1/utils/block.go | 65 + routers/api/v1/utils/git.go | 99 ++ routers/api/v1/utils/hook.go | 419 ++++++ routers/api/v1/utils/page.go | 18 + 129 files changed, 33437 insertions(+) create mode 100644 routers/api/v1/activitypub/actor.go create mode 100644 routers/api/v1/activitypub/person.go create mode 100644 routers/api/v1/activitypub/repository.go create mode 100644 routers/api/v1/activitypub/repository_test.go create mode 100644 routers/api/v1/activitypub/reqsignature.go create mode 100644 routers/api/v1/activitypub/response.go create mode 100644 routers/api/v1/admin/adopt.go create mode 100644 routers/api/v1/admin/cron.go create mode 100644 routers/api/v1/admin/email.go create mode 100644 routers/api/v1/admin/hooks.go create mode 100644 routers/api/v1/admin/org.go create mode 100644 routers/api/v1/admin/quota.go create mode 100644 routers/api/v1/admin/quota_group.go create mode 100644 routers/api/v1/admin/quota_rule.go create mode 100644 routers/api/v1/admin/repo.go create mode 100644 routers/api/v1/admin/runners.go create mode 100644 routers/api/v1/admin/user.go create mode 100644 routers/api/v1/api.go create mode 100644 routers/api/v1/misc/gitignore.go create mode 100644 routers/api/v1/misc/label_templates.go create mode 100644 routers/api/v1/misc/licenses.go create mode 100644 routers/api/v1/misc/markup.go create mode 100644 routers/api/v1/misc/markup_test.go create mode 100644 routers/api/v1/misc/nodeinfo.go create mode 100644 routers/api/v1/misc/signing.go create mode 100644 routers/api/v1/misc/version.go create mode 100644 routers/api/v1/notify/notifications.go create mode 100644 routers/api/v1/notify/repo.go create mode 100644 routers/api/v1/notify/threads.go create mode 100644 routers/api/v1/notify/user.go create mode 100644 routers/api/v1/org/action.go create mode 100644 routers/api/v1/org/avatar.go create mode 100644 routers/api/v1/org/hook.go create mode 100644 routers/api/v1/org/label.go create mode 100644 routers/api/v1/org/member.go create mode 100644 routers/api/v1/org/org.go create mode 100644 routers/api/v1/org/quota.go create mode 100644 routers/api/v1/org/team.go create mode 100644 routers/api/v1/packages/package.go create mode 100644 routers/api/v1/repo/action.go create mode 100644 routers/api/v1/repo/avatar.go create mode 100644 routers/api/v1/repo/blob.go create mode 100644 routers/api/v1/repo/branch.go create mode 100644 routers/api/v1/repo/collaborators.go create mode 100644 routers/api/v1/repo/commits.go create mode 100644 routers/api/v1/repo/compare.go create mode 100644 routers/api/v1/repo/file.go create mode 100644 routers/api/v1/repo/flags.go create mode 100644 routers/api/v1/repo/fork.go create mode 100644 routers/api/v1/repo/git_hook.go create mode 100644 routers/api/v1/repo/git_ref.go create mode 100644 routers/api/v1/repo/hook.go create mode 100644 routers/api/v1/repo/hook_test.go create mode 100644 routers/api/v1/repo/issue.go create mode 100644 routers/api/v1/repo/issue_attachment.go create mode 100644 routers/api/v1/repo/issue_comment.go create mode 100644 routers/api/v1/repo/issue_comment_attachment.go create mode 100644 routers/api/v1/repo/issue_dependency.go create mode 100644 routers/api/v1/repo/issue_label.go create mode 100644 routers/api/v1/repo/issue_pin.go create mode 100644 routers/api/v1/repo/issue_reaction.go create mode 100644 routers/api/v1/repo/issue_stopwatch.go create mode 100644 routers/api/v1/repo/issue_subscription.go create mode 100644 routers/api/v1/repo/issue_tracked_time.go create mode 100644 routers/api/v1/repo/key.go create mode 100644 routers/api/v1/repo/label.go create mode 100644 routers/api/v1/repo/language.go create mode 100644 routers/api/v1/repo/main_test.go create mode 100644 routers/api/v1/repo/migrate.go create mode 100644 routers/api/v1/repo/milestone.go create mode 100644 routers/api/v1/repo/mirror.go create mode 100644 routers/api/v1/repo/notes.go create mode 100644 routers/api/v1/repo/patch.go create mode 100644 routers/api/v1/repo/pull.go create mode 100644 routers/api/v1/repo/pull_review.go create mode 100644 routers/api/v1/repo/release.go create mode 100644 routers/api/v1/repo/release_attachment.go create mode 100644 routers/api/v1/repo/release_tags.go create mode 100644 routers/api/v1/repo/repo.go create mode 100644 routers/api/v1/repo/repo_test.go create mode 100644 routers/api/v1/repo/star.go create mode 100644 routers/api/v1/repo/status.go create mode 100644 routers/api/v1/repo/subscriber.go create mode 100644 routers/api/v1/repo/tag.go create mode 100644 routers/api/v1/repo/teams.go create mode 100644 routers/api/v1/repo/topic.go create mode 100644 routers/api/v1/repo/transfer.go create mode 100644 routers/api/v1/repo/tree.go create mode 100644 routers/api/v1/repo/wiki.go create mode 100644 routers/api/v1/settings/settings.go create mode 100644 routers/api/v1/shared/quota.go create mode 100644 routers/api/v1/shared/runners.go create mode 100644 routers/api/v1/swagger/action.go create mode 100644 routers/api/v1/swagger/activity.go create mode 100644 routers/api/v1/swagger/activitypub.go create mode 100644 routers/api/v1/swagger/app.go create mode 100644 routers/api/v1/swagger/cron.go create mode 100644 routers/api/v1/swagger/issue.go create mode 100644 routers/api/v1/swagger/key.go create mode 100644 routers/api/v1/swagger/misc.go create mode 100644 routers/api/v1/swagger/nodeinfo.go create mode 100644 routers/api/v1/swagger/notify.go create mode 100644 routers/api/v1/swagger/options.go create mode 100644 routers/api/v1/swagger/org.go create mode 100644 routers/api/v1/swagger/package.go create mode 100644 routers/api/v1/swagger/quota.go create mode 100644 routers/api/v1/swagger/repo.go create mode 100644 routers/api/v1/swagger/settings.go create mode 100644 routers/api/v1/swagger/user.go create mode 100644 routers/api/v1/user/action.go create mode 100644 routers/api/v1/user/app.go create mode 100644 routers/api/v1/user/avatar.go create mode 100644 routers/api/v1/user/email.go create mode 100644 routers/api/v1/user/follower.go create mode 100644 routers/api/v1/user/gpg_key.go create mode 100644 routers/api/v1/user/helper.go create mode 100644 routers/api/v1/user/hook.go create mode 100644 routers/api/v1/user/key.go create mode 100644 routers/api/v1/user/quota.go create mode 100644 routers/api/v1/user/repo.go create mode 100644 routers/api/v1/user/runners.go create mode 100644 routers/api/v1/user/settings.go create mode 100644 routers/api/v1/user/star.go create mode 100644 routers/api/v1/user/user.go create mode 100644 routers/api/v1/user/watch.go create mode 100644 routers/api/v1/utils/block.go create mode 100644 routers/api/v1/utils/git.go create mode 100644 routers/api/v1/utils/hook.go create mode 100644 routers/api/v1/utils/page.go (limited to 'routers/api/v1') diff --git a/routers/api/v1/activitypub/actor.go b/routers/api/v1/activitypub/actor.go new file mode 100644 index 0000000..4f128e7 --- /dev/null +++ b/routers/api/v1/activitypub/actor.go @@ -0,0 +1,83 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Actor function returns the instance's Actor +func Actor(ctx *context.APIContext) { + // swagger:operation GET /activitypub/actor activitypub activitypubInstanceActor + // --- + // summary: Returns the instance's Actor + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := user_model.APActorUserAPActorID() + actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) + + actor.PreferredUsername = ap.NaturalLanguageValuesNew() + err := actor.PreferredUsername.Set("en", ap.Content(setting.Domain)) + if err != nil { + ctx.ServerError("PreferredUsername.Set", err) + return + } + + actor.URL = ap.IRI(setting.AppURL) + + actor.Inbox = ap.IRI(link + "/inbox") + actor.Outbox = ap.IRI(link + "/outbox") + + actor.PublicKey.ID = ap.IRI(link + "#main-key") + actor.PublicKey.Owner = ap.IRI(link) + + publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPActorUser()) + if err != nil { + ctx.ServerError("GetPublicKey", err) + return + } + actor.PublicKey.PublicKeyPem = publicKeyPem + + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + jsonld.IRI(ap.SecurityContextURI), + ).Marshal(actor) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} + +// ActorInbox function handles the incoming data for the instance Actor +func ActorInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/actor/inbox activitypub activitypubInstanceActorInbox + // --- + // summary: Send to the inbox + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go new file mode 100644 index 0000000..995a148 --- /dev/null +++ b/routers/api/v1/activitypub/person.go @@ -0,0 +1,106 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Person function returns the Person actor for a user +func Person(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user-id/{user-id} activitypub activitypubPerson + // --- + // summary: Returns the Person actor for a user + // produces: + // - application/json + // parameters: + // - name: user-id + // in: path + // description: user ID of the user + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + // TODO: the setting.AppURL during the test doesn't follow the definition: "It always has a '/' suffix" + link := fmt.Sprintf("%s/api/v1/activitypub/user-id/%d", strings.TrimSuffix(setting.AppURL, "/"), ctx.ContextUser.ID) + person := ap.PersonNew(ap.IRI(link)) + + person.Name = ap.NaturalLanguageValuesNew() + err := person.Name.Set("en", ap.Content(ctx.ContextUser.FullName)) + if err != nil { + ctx.ServerError("Set Name", err) + return + } + + person.PreferredUsername = ap.NaturalLanguageValuesNew() + err = person.PreferredUsername.Set("en", ap.Content(ctx.ContextUser.Name)) + if err != nil { + ctx.ServerError("Set PreferredUsername", err) + return + } + + person.URL = ap.IRI(ctx.ContextUser.HTMLURL()) + + person.Icon = ap.Image{ + Type: ap.ImageType, + MediaType: "image/png", + URL: ap.IRI(ctx.ContextUser.AvatarLink(ctx)), + } + + person.Inbox = ap.IRI(link + "/inbox") + person.Outbox = ap.IRI(link + "/outbox") + + person.PublicKey.ID = ap.IRI(link + "#main-key") + person.PublicKey.Owner = ap.IRI(link) + + publicKeyPem, err := activitypub.GetPublicKey(ctx, ctx.ContextUser) + if err != nil { + ctx.ServerError("GetPublicKey", err) + return + } + person.PublicKey.PublicKeyPem = publicKeyPem + + binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} + +// PersonInbox function handles the incoming data for a user inbox +func PersonInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/user-id/{user-id}/inbox activitypub activitypubPersonInbox + // --- + // summary: Send to the inbox + // produces: + // - application/json + // parameters: + // - name: user-id + // in: path + // description: user ID of the user + // type: integer + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/activitypub/repository.go b/routers/api/v1/activitypub/repository.go new file mode 100644 index 0000000..bc6e790 --- /dev/null +++ b/routers/api/v1/activitypub/repository.go @@ -0,0 +1,80 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/federation" + + ap "github.com/go-ap/activitypub" +) + +// Repository function returns the Repository actor for a repo +func Repository(ctx *context.APIContext) { + // swagger:operation GET /activitypub/repository-id/{repository-id} activitypub activitypubRepository + // --- + // summary: Returns the Repository actor for a repo + // produces: + // - application/json + // parameters: + // - name: repository-id + // in: path + // description: repository ID of the repo + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%d", strings.TrimSuffix(setting.AppURL, "/"), ctx.Repo.Repository.ID) + repo := forgefed.RepositoryNew(ap.IRI(link)) + + repo.Name = ap.NaturalLanguageValuesNew() + err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "Set Name", err) + return + } + response(ctx, repo) +} + +// PersonInbox function handles the incoming data for a repository inbox +func RepositoryInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/repository-id/{repository-id}/inbox activitypub activitypubRepositoryInbox + // --- + // summary: Send to the inbox + // produces: + // - application/json + // parameters: + // - name: repository-id + // in: path + // description: repository ID of the repo + // type: integer + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/ForgeLike" + // responses: + // "204": + // "$ref": "#/responses/empty" + + repository := ctx.Repo.Repository + log.Info("RepositoryInbox: repo: %v", repository) + + form := web.GetForm(ctx) + httpStatus, title, err := federation.ProcessLikeActivity(ctx, form, repository.ID) + if err != nil { + ctx.Error(httpStatus, title, err) + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/activitypub/repository_test.go b/routers/api/v1/activitypub/repository_test.go new file mode 100644 index 0000000..acd588d --- /dev/null +++ b/routers/api/v1/activitypub/repository_test.go @@ -0,0 +1,27 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "testing" + + "code.gitea.io/gitea/models/user" +) + +func Test_UserEmailValidate(t *testing.T) { + sut := "ab@cd.ef" + if err := user.ValidateEmail(sut); err != nil { + t.Errorf("sut should be valid, %v, %v", sut, err) + } + + sut = "83ce13c8-af0b-4112-8327-55a54e54e664@code.cartoon-aa.xyz" + if err := user.ValidateEmail(sut); err != nil { + t.Errorf("sut should be valid, %v, %v", sut, err) + } + + sut = "1" + if err := user.ValidateEmail(sut); err == nil { + t.Errorf("sut should not be valid, %v", sut) + } +} diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go new file mode 100644 index 0000000..6003f66 --- /dev/null +++ b/routers/api/v1/activitypub/reqsignature.go @@ -0,0 +1,99 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/http" + "net/url" + + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + gitea_context "code.gitea.io/gitea/services/context" + + ap "github.com/go-ap/activitypub" + "github.com/go-fed/httpsig" +) + +func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) { + person := ap.PersonNew(ap.IRI(keyID.String())) + err = person.UnmarshalJSON(b) + if err != nil { + return nil, fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err) + } + pubKey := person.PublicKey + if pubKey.ID.String() != keyID.String() { + return nil, fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b)) + } + pubKeyPem := pubKey.PublicKeyPem + block, _ := pem.Decode([]byte(pubKeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type") + } + p, err = x509.ParsePKIXPublicKey(block.Bytes) + return p, err +} + +func fetch(iri *url.URL) (b []byte, err error) { + req := httplib.NewRequest(iri.String(), http.MethodGet) + req.Header("Accept", activitypub.ActivityStreamsContentType) + req.Header("User-Agent", "Gitea/"+setting.AppVer) + resp, err := req.Response() + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status) + } + b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize)) + return b, err +} + +func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) { + r := ctx.Req + + // 1. Figure out what key we need to verify + v, err := httpsig.NewVerifier(r) + if err != nil { + return false, err + } + ID := v.KeyId() + idIRI, err := url.Parse(ID) + if err != nil { + return false, err + } + // 2. Fetch the public key of the other actor + b, err := fetch(idIRI) + if err != nil { + return false, err + } + pubKey, err := getPublicKeyFromResponse(b, idIRI) + if err != nil { + return false, err + } + // 3. Verify the other actor's key + algo := httpsig.Algorithm(setting.Federation.Algorithms[0]) + authenticated = v.Verify(pubKey, algo) == nil + return authenticated, err +} + +// ReqHTTPSignature function +func ReqHTTPSignature() func(ctx *gitea_context.APIContext) { + return func(ctx *gitea_context.APIContext) { + if authenticated, err := verifyHTTPSignatures(ctx); err != nil { + log.Warn("verifyHttpSignatures failed: %v", err) + ctx.Error(http.StatusBadRequest, "reqSignature", "request signature verification failed") + } else if !authenticated { + ctx.Error(http.StatusForbidden, "reqSignature", "request signature verification failed") + } + } +} diff --git a/routers/api/v1/activitypub/response.go b/routers/api/v1/activitypub/response.go new file mode 100644 index 0000000..42ef375 --- /dev/null +++ b/routers/api/v1/activitypub/response.go @@ -0,0 +1,35 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "net/http" + + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Respond with an ActivityStreams object +func response(ctx *context.APIContext, v any) { + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + jsonld.IRI(ap.SecurityContextURI), + jsonld.IRI(forgefed.ForgeFedNamespaceURI), + ).Marshal(v) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} diff --git a/routers/api/v1/admin/adopt.go b/routers/api/v1/admin/adopt.go new file mode 100644 index 0000000..a4708fe --- /dev/null +++ b/routers/api/v1/admin/adopt.go @@ -0,0 +1,180 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// ListUnadoptedRepositories lists the unadopted repositories that match the provided names +func ListUnadoptedRepositories(ctx *context.APIContext) { + // swagger:operation GET /admin/unadopted admin adminUnadoptedList + // --- + // summary: List unadopted repositories + // produces: + // - application/json + // parameters: + // - 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 + // - name: pattern + // in: query + // description: pattern of repositories to search for + // type: string + // responses: + // "200": + // "$ref": "#/responses/StringSlice" + // "403": + // "$ref": "#/responses/forbidden" + + listOptions := utils.GetListOptions(ctx) + if listOptions.Page == 0 { + listOptions.Page = 1 + } + repoNames, count, err := repo_service.ListUnadoptedRepositories(ctx, ctx.FormString("query"), &listOptions) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetTotalCountHeader(int64(count)) + + ctx.JSON(http.StatusOK, repoNames) +} + +// AdoptRepository will adopt an unadopted repository +func AdoptRepository(ctx *context.APIContext) { + // swagger:operation POST /admin/unadopted/{owner}/{repo} admin adminAdoptRepository + // --- + // summary: Adopt unadopted files as a repository + // 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 + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "403": + // "$ref": "#/responses/forbidden" + ownerName := ctx.Params(":username") + repoName := ctx.Params(":reponame") + + ctxUser, err := user_model.GetUserByName(ctx, ownerName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound() + return + } + ctx.InternalServerError(err) + return + } + + // check not a repo + has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName) + if err != nil { + ctx.InternalServerError(err) + return + } + isDir, err := util.IsDir(repo_model.RepoPath(ctxUser.Name, repoName)) + if err != nil { + ctx.InternalServerError(err) + return + } + if has || !isDir { + ctx.NotFound() + return + } + if _, err := repo_service.AdoptRepository(ctx, ctx.Doer, ctxUser, repo_service.CreateRepoOptions{ + Name: repoName, + IsPrivate: true, + }); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteUnadoptedRepository will delete an unadopted repository +func DeleteUnadoptedRepository(ctx *context.APIContext) { + // swagger:operation DELETE /admin/unadopted/{owner}/{repo} admin adminDeleteUnadoptedRepository + // --- + // summary: Delete unadopted files + // 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 + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + ownerName := ctx.Params(":username") + repoName := ctx.Params(":reponame") + + ctxUser, err := user_model.GetUserByName(ctx, ownerName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound() + return + } + ctx.InternalServerError(err) + return + } + + // check not a repo + has, err := repo_model.IsRepositoryModelExist(ctx, ctxUser, repoName) + if err != nil { + ctx.InternalServerError(err) + return + } + isDir, err := util.IsDir(repo_model.RepoPath(ctxUser.Name, repoName)) + if err != nil { + ctx.InternalServerError(err) + return + } + if has || !isDir { + ctx.NotFound() + return + } + + if err := repo_service.DeleteUnadoptedRepository(ctx, ctx.Doer, ctxUser, repoName); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/admin/cron.go b/routers/api/v1/admin/cron.go new file mode 100644 index 0000000..e1ca604 --- /dev/null +++ b/routers/api/v1/admin/cron.go @@ -0,0 +1,86 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/cron" +) + +// ListCronTasks api for getting cron tasks +func ListCronTasks(ctx *context.APIContext) { + // swagger:operation GET /admin/cron admin adminCronList + // --- + // summary: List cron tasks + // produces: + // - application/json + // parameters: + // - 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/CronList" + // "403": + // "$ref": "#/responses/forbidden" + tasks := cron.ListTasks() + count := len(tasks) + + listOpts := utils.GetListOptions(ctx) + tasks = util.PaginateSlice(tasks, listOpts.Page, listOpts.PageSize).(cron.TaskTable) + + res := make([]structs.Cron, len(tasks)) + for i, task := range tasks { + res[i] = structs.Cron{ + Name: task.Name, + Schedule: task.Spec, + Next: task.Next, + Prev: task.Prev, + ExecTimes: task.ExecTimes, + } + } + + ctx.SetTotalCountHeader(int64(count)) + ctx.JSON(http.StatusOK, res) +} + +// PostCronTask api for getting cron tasks +func PostCronTask(ctx *context.APIContext) { + // swagger:operation POST /admin/cron/{task} admin adminCronRun + // --- + // summary: Run cron task + // produces: + // - application/json + // parameters: + // - name: task + // in: path + // description: task to run + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + task := cron.GetTask(ctx.Params(":task")) + if task == nil { + ctx.NotFound() + return + } + task.Run() + log.Trace("Cron Task %s started by admin(%s)", task.Name, ctx.Doer.Name) + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/admin/email.go b/routers/api/v1/admin/email.go new file mode 100644 index 0000000..ba963e9 --- /dev/null +++ b/routers/api/v1/admin/email.go @@ -0,0 +1,87 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetAllEmails +func GetAllEmails(ctx *context.APIContext) { + // swagger:operation GET /admin/emails admin adminGetAllEmails + // --- + // summary: List all emails + // produces: + // - application/json + // parameters: + // - 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/EmailList" + // "403": + // "$ref": "#/responses/forbidden" + + listOptions := utils.GetListOptions(ctx) + + emails, maxResults, err := user_model.SearchEmails(ctx, &user_model.SearchEmailOptions{ + Keyword: ctx.Params(":email"), + ListOptions: listOptions, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetAllEmails", err) + return + } + + results := make([]*api.Email, len(emails)) + for i := range emails { + results[i] = convert.ToEmailSearch(emails[i]) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &results) +} + +// SearchEmail +func SearchEmail(ctx *context.APIContext) { + // swagger:operation GET /admin/emails/search admin adminSearchEmails + // --- + // summary: Search all emails + // produces: + // - application/json + // parameters: + // - name: q + // in: query + // description: keyword + // type: string + // - 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/EmailList" + // "403": + // "$ref": "#/responses/forbidden" + + ctx.SetParams(":email", ctx.FormTrim("q")) + GetAllEmails(ctx) +} diff --git a/routers/api/v1/admin/hooks.go b/routers/api/v1/admin/hooks.go new file mode 100644 index 0000000..b246cb6 --- /dev/null +++ b/routers/api/v1/admin/hooks.go @@ -0,0 +1,176 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/setting" + 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/utils" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +// ListHooks list system's webhooks +func ListHooks(ctx *context.APIContext) { + // swagger:operation GET /admin/hooks admin adminListHooks + // --- + // summary: List system's webhooks + // produces: + // - application/json + // parameters: + // - 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/HookList" + + sysHooks, err := webhook.GetSystemWebhooks(ctx, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetSystemWebhooks", err) + return + } + hooks := make([]*api.Hook, len(sysHooks)) + for i, hook := range sysHooks { + h, err := webhook_service.ToHook(setting.AppURL+"/admin", hook) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToHook", err) + return + } + hooks[i] = h + } + ctx.JSON(http.StatusOK, hooks) +} + +// GetHook get an organization's hook by id +func GetHook(ctx *context.APIContext) { + // swagger:operation GET /admin/hooks/{id} admin adminGetHook + // --- + // summary: Get a hook + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Hook" + + hookID := ctx.ParamsInt64(":id") + hook, err := webhook.GetSystemOrDefaultWebhook(ctx, hookID) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetSystemOrDefaultWebhook", err) + } + return + } + h, err := webhook_service.ToHook("/admin/", hook) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToHook", err) + return + } + ctx.JSON(http.StatusOK, h) +} + +// CreateHook create a hook for an organization +func CreateHook(ctx *context.APIContext) { + // swagger:operation POST /admin/hooks admin adminCreateHook + // --- + // summary: Create a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateHookOption" + // responses: + // "201": + // "$ref": "#/responses/Hook" + + form := web.GetForm(ctx).(*api.CreateHookOption) + + utils.AddSystemHook(ctx, form) +} + +// EditHook modify a hook of a repository +func EditHook(ctx *context.APIContext) { + // swagger:operation PATCH /admin/hooks/{id} admin adminEditHook + // --- + // summary: Update a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to update + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditHookOption" + // responses: + // "200": + // "$ref": "#/responses/Hook" + + form := web.GetForm(ctx).(*api.EditHookOption) + + // TODO in body params + hookID := ctx.ParamsInt64(":id") + utils.EditSystemHook(ctx, form, hookID) +} + +// DeleteHook delete a system hook +func DeleteHook(ctx *context.APIContext) { + // swagger:operation DELETE /admin/hooks/{id} admin adminDeleteHook + // --- + // summary: Delete a hook + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + hookID := ctx.ParamsInt64(":id") + if err := webhook.DeleteDefaultSystemWebhook(ctx, hookID); err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "DeleteDefaultSystemWebhook", err) + } + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/admin/org.go b/routers/api/v1/admin/org.go new file mode 100644 index 0000000..a5c299b --- /dev/null +++ b/routers/api/v1/admin/org.go @@ -0,0 +1,123 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// CreateOrg api for create organization +func CreateOrg(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/orgs admin adminCreateOrg + // --- + // summary: Create an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user that will own the created organization + // type: string + // required: true + // - name: organization + // in: body + // required: true + // schema: { "$ref": "#/definitions/CreateOrgOption" } + // responses: + // "201": + // "$ref": "#/responses/Organization" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateOrgOption) + + visibility := api.VisibleTypePublic + if form.Visibility != "" { + visibility = api.VisibilityModes[form.Visibility] + } + + org := &organization.Organization{ + Name: form.UserName, + FullName: form.FullName, + Description: form.Description, + Website: form.Website, + Location: form.Location, + IsActive: true, + Type: user_model.UserTypeOrganization, + Visibility: visibility, + } + + if err := organization.CreateOrganization(ctx, org, ctx.ContextUser); err != nil { + if user_model.IsErrUserAlreadyExist(err) || + db.IsErrNameReserved(err) || + db.IsErrNameCharsNotAllowed(err) || + db.IsErrNamePatternNotAllowed(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrganization", err) + } + return + } + + ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org)) +} + +// GetAllOrgs API for getting information of all the organizations +func GetAllOrgs(ctx *context.APIContext) { + // swagger:operation GET /admin/orgs admin adminGetAllOrgs + // --- + // summary: List all organizations + // produces: + // - application/json + // parameters: + // - 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/OrganizationList" + // "403": + // "$ref": "#/responses/forbidden" + + listOptions := utils.GetListOptions(ctx) + + users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeOrganization, + OrderBy: db.SearchOrderByAlphabetically, + ListOptions: listOptions, + Visible: []api.VisibleType{api.VisibleTypePublic, api.VisibleTypeLimited, api.VisibleTypePrivate}, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchOrganizations", err) + return + } + orgs := make([]*api.Organization, len(users)) + for i := range users { + orgs[i] = convert.ToOrganization(ctx, organization.OrgFromUser(users[i])) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &orgs) +} diff --git a/routers/api/v1/admin/quota.go b/routers/api/v1/admin/quota.go new file mode 100644 index 0000000..1e7c11e --- /dev/null +++ b/routers/api/v1/admin/quota.go @@ -0,0 +1,53 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetUserQuota return information about a user's quota +func GetUserQuota(ctx *context.APIContext) { + // swagger:operation GET /admin/users/{username}/quota admin adminGetUserQuota + // --- + // summary: Get the user's quota info + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user to query + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/QuotaInfo" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + used, err := quota_model.GetUsedForUser(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.GetUsedForUser", err) + return + } + + groups, err := quota_model.GetGroupsForUser(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupsForUser", err) + return + } + + result := convert.ToQuotaInfo(used, groups, true) + ctx.JSON(http.StatusOK, &result) +} diff --git a/routers/api/v1/admin/quota_group.go b/routers/api/v1/admin/quota_group.go new file mode 100644 index 0000000..e20b361 --- /dev/null +++ b/routers/api/v1/admin/quota_group.go @@ -0,0 +1,436 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + go_context "context" + "net/http" + + "code.gitea.io/gitea/models/db" + quota_model "code.gitea.io/gitea/models/quota" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListQuotaGroups returns all the quota groups +func ListQuotaGroups(ctx *context.APIContext) { + // swagger:operation GET /admin/quota/groups admin adminListQuotaGroups + // --- + // summary: List the available quota groups + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/QuotaGroupList" + // "403": + // "$ref": "#/responses/forbidden" + + groups, err := quota_model.ListGroups(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.ListGroups", err) + return + } + for _, group := range groups { + if err = group.LoadRules(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.group.LoadRules", err) + return + } + } + + ctx.JSON(http.StatusOK, convert.ToQuotaGroupList(groups, true)) +} + +func createQuotaGroupWithRules(ctx go_context.Context, opts *api.CreateQuotaGroupOptions) (*quota_model.Group, error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + group, err := quota_model.CreateGroup(ctx, opts.Name) + if err != nil { + return nil, err + } + + for _, rule := range opts.Rules { + exists, err := quota_model.DoesRuleExist(ctx, rule.Name) + if err != nil { + return nil, err + } + if !exists { + var limit int64 + if rule.Limit != nil { + limit = *rule.Limit + } + + subjects, err := toLimitSubjects(rule.Subjects) + if err != nil { + return nil, err + } + + _, err = quota_model.CreateRule(ctx, rule.Name, limit, *subjects) + if err != nil { + return nil, err + } + } + if err = group.AddRuleByName(ctx, rule.Name); err != nil { + return nil, err + } + } + + if err = group.LoadRules(ctx); err != nil { + return nil, err + } + + return group, committer.Commit() +} + +// CreateQuotaGroup creates a new quota group +func CreateQuotaGroup(ctx *context.APIContext) { + // swagger:operation POST /admin/quota/groups admin adminCreateQuotaGroup + // --- + // summary: Create a new quota group + // produces: + // - application/json + // parameters: + // - name: group + // in: body + // description: Definition of the quota group + // schema: + // "$ref": "#/definitions/CreateQuotaGroupOptions" + // required: true + // responses: + // "201": + // "$ref": "#/responses/QuotaGroup" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "409": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateQuotaGroupOptions) + + group, err := createQuotaGroupWithRules(ctx, form) + if err != nil { + if quota_model.IsErrGroupAlreadyExists(err) { + ctx.Error(http.StatusConflict, "", err) + } else if quota_model.IsErrParseLimitSubjectUnrecognized(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "quota_model.CreateGroup", err) + } + return + } + ctx.JSON(http.StatusCreated, convert.ToQuotaGroup(*group, true)) +} + +// ListUsersInQuotaGroup lists all the users in a quota group +func ListUsersInQuotaGroup(ctx *context.APIContext) { + // swagger:operation GET /admin/quota/groups/{quotagroup}/users admin adminListUsersInQuotaGroup + // --- + // summary: List users in a quota group + // produces: + // - application/json + // parameters: + // - name: quotagroup + // in: path + // description: quota group to list members of + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/UserList" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + users, err := quota_model.ListUsersInGroup(ctx, ctx.QuotaGroup.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.ListUsersInGroup", err) + return + } + ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, users)) +} + +// AddUserToQuotaGroup adds a user to a quota group +func AddUserToQuotaGroup(ctx *context.APIContext) { + // swagger:operation PUT /admin/quota/groups/{quotagroup}/users/{username} admin adminAddUserToQuotaGroup + // --- + // summary: Add a user to a quota group + // produces: + // - application/json + // parameters: + // - name: quotagroup + // in: path + // description: quota group to add the user to + // type: string + // required: true + // - name: username + // in: path + // description: username of the user to add to the quota group + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + + err := ctx.QuotaGroup.AddUserByID(ctx, ctx.ContextUser.ID) + if err != nil { + if quota_model.IsErrUserAlreadyInGroup(err) { + ctx.Error(http.StatusConflict, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "quota_group.group.AddUserByID", err) + } + return + } + ctx.Status(http.StatusNoContent) +} + +// RemoveUserFromQuotaGroup removes a user from a quota group +func RemoveUserFromQuotaGroup(ctx *context.APIContext) { + // swagger:operation DELETE /admin/quota/groups/{quotagroup}/users/{username} admin adminRemoveUserFromQuotaGroup + // --- + // summary: Remove a user from a quota group + // produces: + // - application/json + // parameters: + // - name: quotagroup + // in: path + // description: quota group to remove a user from + // type: string + // required: true + // - name: username + // in: path + // description: username of the user to remove from the quota group + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + err := ctx.QuotaGroup.RemoveUserByID(ctx, ctx.ContextUser.ID) + if err != nil { + if quota_model.IsErrUserNotInGroup(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "quota_model.group.RemoveUserByID", err) + } + return + } + ctx.Status(http.StatusNoContent) +} + +// SetUserQuotaGroups moves the user to specific quota groups +func SetUserQuotaGroups(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/quota/groups admin adminSetUserQuotaGroups + // --- + // summary: Set the user's quota groups to a given list. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user to modify the quota groups from + // type: string + // required: true + // - name: groups + // in: body + // description: list of groups that the user should be a member of + // schema: + // "$ref": "#/definitions/SetUserQuotaGroupsOptions" + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.SetUserQuotaGroupsOptions) + + err := quota_model.SetUserGroups(ctx, ctx.ContextUser.ID, form.Groups) + if err != nil { + if quota_model.IsErrGroupNotFound(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "quota_model.SetUserGroups", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteQuotaGroup deletes a quota group +func DeleteQuotaGroup(ctx *context.APIContext) { + // swagger:operation DELETE /admin/quota/groups/{quotagroup} admin adminDeleteQuotaGroup + // --- + // summary: Delete a quota group + // produces: + // - application/json + // parameters: + // - name: quotagroup + // in: path + // description: quota group to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + err := quota_model.DeleteGroupByName(ctx, ctx.QuotaGroup.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.DeleteGroupByName", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// GetQuotaGroup returns information about a quota group +func GetQuotaGroup(ctx *context.APIContext) { + // swagger:operation GET /admin/quota/groups/{quotagroup} admin adminGetQuotaGroup + // --- + // summary: Get information about the quota group + // produces: + // - application/json + // parameters: + // - name: quotagroup + // in: path + // description: quota group to query + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/QuotaGroup" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + ctx.JSON(http.StatusOK, convert.ToQuotaGroup(*ctx.QuotaGroup, true)) +} + +// AddRuleToQuotaGroup adds a rule to a quota group +func AddRuleToQuotaGroup(ctx *context.APIContext) { + // swagger:operation PUT /admin/quota/groups/{quotagroup}/rules/{quotarule} admin adminAddRuleToQuotaGroup + // --- + // summary: Adds a rule to a quota group + // produces: + // - application/json + // parameters: + // - name: quotagroup + // in: path + // description: quota group to add a rule to + // type: string + // required: true + // - name: quotarule + // in: path + // description: the name of the quota rule to add to the group + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + + err := ctx.QuotaGroup.AddRuleByName(ctx, ctx.QuotaRule.Name) + if err != nil { + if quota_model.IsErrRuleAlreadyInGroup(err) { + ctx.Error(http.StatusConflict, "", err) + } else if quota_model.IsErrRuleNotFound(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "quota_model.group.AddRuleByName", err) + } + return + } + ctx.Status(http.StatusNoContent) +} + +// RemoveRuleFromQuotaGroup removes a rule from a quota group +func RemoveRuleFromQuotaGroup(ctx *context.APIContext) { + // swagger:operation DELETE /admin/quota/groups/{quotagroup}/rules/{quotarule} admin adminRemoveRuleFromQuotaGroup + // --- + // summary: Removes a rule from a quota group + // produces: + // - application/json + // parameters: + // - name: quotagroup + // in: path + // description: quota group to remove a rule from + // type: string + // required: true + // - name: quotarule + // in: path + // description: the name of the quota rule to remove from the group + // type: string + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + err := ctx.QuotaGroup.RemoveRuleByName(ctx, ctx.QuotaRule.Name) + if err != nil { + if quota_model.IsErrRuleNotInGroup(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "quota_model.group.RemoveRuleByName", err) + } + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/admin/quota_rule.go b/routers/api/v1/admin/quota_rule.go new file mode 100644 index 0000000..85c05e1 --- /dev/null +++ b/routers/api/v1/admin/quota_rule.go @@ -0,0 +1,219 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "fmt" + "net/http" + + quota_model "code.gitea.io/gitea/models/quota" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func toLimitSubjects(subjStrings []string) (*quota_model.LimitSubjects, error) { + subjects := make(quota_model.LimitSubjects, len(subjStrings)) + for i := range len(subjStrings) { + subj, err := quota_model.ParseLimitSubject(subjStrings[i]) + if err != nil { + return nil, err + } + subjects[i] = subj + } + + return &subjects, nil +} + +// ListQuotaRules lists all the quota rules +func ListQuotaRules(ctx *context.APIContext) { + // swagger:operation GET /admin/quota/rules admin adminListQuotaRules + // --- + // summary: List the available quota rules + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/QuotaRuleInfoList" + // "403": + // "$ref": "#/responses/forbidden" + + rules, err := quota_model.ListRules(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.ListQuotaRules", err) + return + } + + result := make([]api.QuotaRuleInfo, len(rules)) + for i := range len(rules) { + result[i] = convert.ToQuotaRuleInfo(rules[i], true) + } + + ctx.JSON(http.StatusOK, result) +} + +// CreateQuotaRule creates a new quota rule +func CreateQuotaRule(ctx *context.APIContext) { + // swagger:operation POST /admin/quota/rules admin adminCreateQuotaRule + // --- + // summary: Create a new quota rule + // produces: + // - application/json + // parameters: + // - name: rule + // in: body + // description: Definition of the quota rule + // schema: + // "$ref": "#/definitions/CreateQuotaRuleOptions" + // required: true + // responses: + // "201": + // "$ref": "#/responses/QuotaRuleInfo" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "409": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateQuotaRuleOptions) + + if form.Limit == nil { + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", fmt.Errorf("[Limit]: Required")) + return + } + + subjects, err := toLimitSubjects(form.Subjects) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err) + return + } + + rule, err := quota_model.CreateRule(ctx, form.Name, *form.Limit, *subjects) + if err != nil { + if quota_model.IsErrRuleAlreadyExists(err) { + ctx.Error(http.StatusConflict, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "quota_model.CreateRule", err) + } + return + } + ctx.JSON(http.StatusCreated, convert.ToQuotaRuleInfo(*rule, true)) +} + +// GetQuotaRule returns information about the specified quota rule +func GetQuotaRule(ctx *context.APIContext) { + // swagger:operation GET /admin/quota/rules/{quotarule} admin adminGetQuotaRule + // --- + // summary: Get information about a quota rule + // produces: + // - application/json + // parameters: + // - name: quotarule + // in: path + // description: quota rule to query + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/QuotaRuleInfo" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + ctx.JSON(http.StatusOK, convert.ToQuotaRuleInfo(*ctx.QuotaRule, true)) +} + +// EditQuotaRule changes an existing quota rule +func EditQuotaRule(ctx *context.APIContext) { + // swagger:operation PATCH /admin/quota/rules/{quotarule} admin adminEditQuotaRule + // --- + // summary: Change an existing quota rule + // produces: + // - application/json + // parameters: + // - name: quotarule + // in: path + // description: Quota rule to change + // type: string + // required: true + // - name: rule + // in: body + // schema: + // "$ref": "#/definitions/EditQuotaRuleOptions" + // required: true + // responses: + // "200": + // "$ref": "#/responses/QuotaRuleInfo" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.EditQuotaRuleOptions) + + var subjects *quota_model.LimitSubjects + if form.Subjects != nil { + subjs := make(quota_model.LimitSubjects, len(*form.Subjects)) + for i := range len(*form.Subjects) { + subj, err := quota_model.ParseLimitSubject((*form.Subjects)[i]) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err) + return + } + subjs[i] = subj + } + subjects = &subjs + } + + rule, err := ctx.QuotaRule.Edit(ctx, form.Limit, subjects) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.rule.Edit", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToQuotaRuleInfo(*rule, true)) +} + +// DeleteQuotaRule deletes a quota rule +func DeleteQuotaRule(ctx *context.APIContext) { + // swagger:operation DELETE /admin/quota/rules/{quotarule} admin adminDEleteQuotaRule + // --- + // summary: Deletes a quota rule + // produces: + // - application/json + // parameters: + // - name: quotarule + // in: path + // description: quota rule to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + err := quota_model.DeleteRuleByName(ctx, ctx.QuotaRule.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.DeleteRuleByName", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/admin/repo.go b/routers/api/v1/admin/repo.go new file mode 100644 index 0000000..c119d53 --- /dev/null +++ b/routers/api/v1/admin/repo.go @@ -0,0 +1,49 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/repo" + "code.gitea.io/gitea/services/context" +) + +// CreateRepo api for creating a repository +func CreateRepo(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/repos admin adminCreateRepo + // --- + // summary: Create a repository on behalf of a user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user. This user will own the created repository + // type: string + // required: true + // - name: repository + // in: body + // required: true + // schema: { "$ref": "#/definitions/CreateRepoOption" } + // responses: + // "201": + // "$ref": "#/responses/Repository" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateRepoOption) + + repo.CreateUserRepo(ctx, ctx.ContextUser, *form) +} diff --git a/routers/api/v1/admin/runners.go b/routers/api/v1/admin/runners.go new file mode 100644 index 0000000..329242d --- /dev/null +++ b/routers/api/v1/admin/runners.go @@ -0,0 +1,26 @@ +// Copyright 2023 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" +) + +// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization + +// GetRegistrationToken returns the token to register global runners +func GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /admin/runners/registration-token admin adminGetRunnerRegistrationToken + // --- + // summary: Get an global actions runner registration token + // produces: + // - application/json + // parameters: + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, 0, 0) +} diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go new file mode 100644 index 0000000..9ea210e --- /dev/null +++ b/routers/api/v1/admin/user.go @@ -0,0 +1,509 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "errors" + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/auth/password" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/user" + "code.gitea.io/gitea/routers/api/v1/utils" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/mailer" + user_service "code.gitea.io/gitea/services/user" +) + +func parseAuthSource(ctx *context.APIContext, u *user_model.User, sourceID int64) { + if sourceID == 0 { + return + } + + source, err := auth.GetSourceByID(ctx, sourceID) + if err != nil { + if auth.IsErrSourceNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "auth.GetSourceByID", err) + } + return + } + + u.LoginType = source.Type + u.LoginSource = source.ID +} + +// CreateUser create a user +func CreateUser(ctx *context.APIContext) { + // swagger:operation POST /admin/users admin adminCreateUser + // --- + // summary: Create a user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateUserOption" + // responses: + // "201": + // "$ref": "#/responses/User" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateUserOption) + + u := &user_model.User{ + Name: form.Username, + FullName: form.FullName, + Email: form.Email, + Passwd: form.Password, + MustChangePassword: true, + LoginType: auth.Plain, + LoginName: form.LoginName, + } + if form.MustChangePassword != nil { + u.MustChangePassword = *form.MustChangePassword + } + + parseAuthSource(ctx, u, form.SourceID) + if ctx.Written() { + return + } + + if u.LoginType == auth.Plain { + if len(form.Password) < setting.MinPasswordLength { + err := errors.New("PasswordIsRequired") + ctx.Error(http.StatusBadRequest, "PasswordIsRequired", err) + return + } + + if !password.IsComplexEnough(form.Password) { + err := errors.New("PasswordComplexity") + ctx.Error(http.StatusBadRequest, "PasswordComplexity", err) + return + } + + if err := password.IsPwned(ctx, form.Password); err != nil { + if password.IsErrIsPwnedRequest(err) { + log.Error(err.Error()) + } + ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned")) + return + } + } + + overwriteDefault := &user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(true), + IsRestricted: optional.FromPtr(form.Restricted), + } + + if form.Visibility != "" { + visibility := api.VisibilityModes[form.Visibility] + overwriteDefault.Visibility = &visibility + } + + // Update the user creation timestamp. This can only be done after the user + // record has been inserted into the database; the insert intself will always + // set the creation timestamp to "now". + if form.Created != nil { + u.CreatedUnix = timeutil.TimeStamp(form.Created.Unix()) + u.UpdatedUnix = u.CreatedUnix + } + + if err := user_model.AdminCreateUser(ctx, u, overwriteDefault); err != nil { + if user_model.IsErrUserAlreadyExist(err) || + user_model.IsErrEmailAlreadyUsed(err) || + db.IsErrNameReserved(err) || + db.IsErrNameCharsNotAllowed(err) || + user_model.IsErrEmailCharIsNotSupported(err) || + user_model.IsErrEmailInvalid(err) || + db.IsErrNamePatternNotAllowed(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateUser", err) + } + return + } + + if !user_model.IsEmailDomainAllowed(u.Email) { + ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", u.Email)) + } + + log.Trace("Account created by admin (%s): %s", ctx.Doer.Name, u.Name) + + // Send email notification. + if form.SendNotify { + mailer.SendRegisterNotifyMail(u) + } + ctx.JSON(http.StatusCreated, convert.ToUser(ctx, u, ctx.Doer)) +} + +// EditUser api for modifying a user's information +func EditUser(ctx *context.APIContext) { + // swagger:operation PATCH /admin/users/{username} admin adminEditUser + // --- + // summary: Edit an existing user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user to edit + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditUserOption" + // responses: + // "200": + // "$ref": "#/responses/User" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.EditUserOption) + + // If either LoginSource or LoginName is given, the other must be present too. + if form.SourceID != nil || form.LoginName != nil { + if form.SourceID == nil || form.LoginName == nil { + ctx.Error(http.StatusUnprocessableEntity, "LoginSourceAndLoginName", fmt.Errorf("source_id and login_name must be specified together")) + return + } + } + + authOpts := &user_service.UpdateAuthOptions{ + LoginSource: optional.FromPtr(form.SourceID), + LoginName: optional.FromPtr(form.LoginName), + Password: optional.FromNonDefault(form.Password), + MustChangePassword: optional.FromPtr(form.MustChangePassword), + ProhibitLogin: optional.FromPtr(form.ProhibitLogin), + } + if err := user_service.UpdateAuth(ctx, ctx.ContextUser, authOpts); err != nil { + switch { + case errors.Is(err, password.ErrMinLength): + ctx.Error(http.StatusBadRequest, "PasswordTooShort", fmt.Errorf("password must be at least %d characters", setting.MinPasswordLength)) + case errors.Is(err, password.ErrComplexity): + ctx.Error(http.StatusBadRequest, "PasswordComplexity", err) + case errors.Is(err, password.ErrIsPwned), password.IsErrIsPwnedRequest(err): + ctx.Error(http.StatusBadRequest, "PasswordIsPwned", err) + default: + ctx.Error(http.StatusInternalServerError, "UpdateAuth", err) + } + return + } + + if form.Email != nil { + if err := user_service.AdminAddOrSetPrimaryEmailAddress(ctx, ctx.ContextUser, *form.Email); err != nil { + switch { + case user_model.IsErrEmailCharIsNotSupported(err), user_model.IsErrEmailInvalid(err): + ctx.Error(http.StatusBadRequest, "EmailInvalid", err) + case user_model.IsErrEmailAlreadyUsed(err): + ctx.Error(http.StatusBadRequest, "EmailUsed", err) + default: + ctx.Error(http.StatusInternalServerError, "AddOrSetPrimaryEmailAddress", err) + } + return + } + + if !user_model.IsEmailDomainAllowed(*form.Email) { + ctx.Resp.Header().Add("X-Gitea-Warning", fmt.Sprintf("the domain of user email %s conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", *form.Email)) + } + } + + opts := &user_service.UpdateOptions{ + FullName: optional.FromPtr(form.FullName), + Website: optional.FromPtr(form.Website), + Location: optional.FromPtr(form.Location), + Description: optional.FromPtr(form.Description), + Pronouns: optional.FromPtr(form.Pronouns), + IsActive: optional.FromPtr(form.Active), + IsAdmin: optional.FromPtr(form.Admin), + Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), + AllowGitHook: optional.FromPtr(form.AllowGitHook), + AllowImportLocal: optional.FromPtr(form.AllowImportLocal), + MaxRepoCreation: optional.FromPtr(form.MaxRepoCreation), + AllowCreateOrganization: optional.FromPtr(form.AllowCreateOrganization), + IsRestricted: optional.FromPtr(form.Restricted), + } + + if err := user_service.UpdateUser(ctx, ctx.ContextUser, opts); err != nil { + if models.IsErrDeleteLastAdminUser(err) { + ctx.Error(http.StatusBadRequest, "LastAdmin", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateUser", err) + } + return + } + + log.Trace("Account profile updated by admin (%s): %s", ctx.Doer.Name, ctx.ContextUser.Name) + + ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.ContextUser, ctx.Doer)) +} + +// DeleteUser api for deleting a user +func DeleteUser(ctx *context.APIContext) { + // swagger:operation DELETE /admin/users/{username} admin adminDeleteUser + // --- + // summary: Delete a user + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user to delete + // type: string + // required: true + // - name: purge + // in: query + // description: purge the user from the system completely + // type: boolean + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + // admin should not delete themself + if ctx.ContextUser.ID == ctx.Doer.ID { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("you cannot delete yourself")) + return + } + + if err := user_service.DeleteUser(ctx, ctx.ContextUser, ctx.FormBool("purge")); err != nil { + if models.IsErrUserOwnRepos(err) || + models.IsErrUserHasOrgs(err) || + models.IsErrUserOwnPackages(err) || + models.IsErrDeleteLastAdminUser(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteUser", err) + } + return + } + log.Trace("Account deleted by admin(%s): %s", ctx.Doer.Name, ctx.ContextUser.Name) + + ctx.Status(http.StatusNoContent) +} + +// CreatePublicKey api for creating a public key to a user +func CreatePublicKey(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/keys admin adminCreatePublicKey + // --- + // summary: Add a public key on behalf of a user + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // - name: key + // in: body + // schema: + // "$ref": "#/definitions/CreateKeyOption" + // responses: + // "201": + // "$ref": "#/responses/PublicKey" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateKeyOption) + + user.CreateUserPublicKey(ctx, *form, ctx.ContextUser.ID) +} + +// DeleteUserPublicKey api for deleting a user's public key +func DeleteUserPublicKey(ctx *context.APIContext) { + // swagger:operation DELETE /admin/users/{username}/keys/{id} admin adminDeleteUserPublicKey + // --- + // summary: Delete a user's public key + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: id + // in: path + // description: id of the key to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if err := asymkey_service.DeletePublicKey(ctx, ctx.ContextUser, ctx.ParamsInt64(":id")); err != nil { + if asymkey_model.IsErrKeyNotExist(err) { + ctx.NotFound() + } else if asymkey_model.IsErrKeyAccessDenied(err) { + ctx.Error(http.StatusForbidden, "", "You do not have access to this key") + } else { + ctx.Error(http.StatusInternalServerError, "DeleteUserPublicKey", err) + } + return + } + log.Trace("Key deleted by admin(%s): %s", ctx.Doer.Name, ctx.ContextUser.Name) + + ctx.Status(http.StatusNoContent) +} + +// SearchUsers API for getting information of the users according the filter conditions +func SearchUsers(ctx *context.APIContext) { + // swagger:operation GET /admin/users admin adminSearchUsers + // --- + // summary: Search users according filter conditions + // produces: + // - application/json + // parameters: + // - name: source_id + // in: query + // description: ID of the user's login source to search for + // type: integer + // format: int64 + // - name: login_name + // in: query + // description: user's login name to search for + // type: string + // - 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/UserList" + // "403": + // "$ref": "#/responses/forbidden" + + listOptions := utils.GetListOptions(ctx) + + users, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Type: user_model.UserTypeIndividual, + LoginName: ctx.FormTrim("login_name"), + SourceID: ctx.FormInt64("source_id"), + OrderBy: db.SearchOrderByAlphabetically, + ListOptions: listOptions, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchUsers", err) + return + } + + results := make([]*api.User, len(users)) + for i := range users { + results[i] = convert.ToUser(ctx, users[i], ctx.Doer) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &results) +} + +// RenameUser api for renaming a user +func RenameUser(ctx *context.APIContext) { + // swagger:operation POST /admin/users/{username}/rename admin adminRenameUser + // --- + // summary: Rename a user + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: existing username of user + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/RenameUserOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + oldName := ctx.ContextUser.Name + newName := web.GetForm(ctx).(*api.RenameUserOption).NewName + + // Check if user name has been changed + if err := user_service.RenameUser(ctx, ctx.ContextUser, newName); err != nil { + switch { + case user_model.IsErrUserAlreadyExist(err): + ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("form.username_been_taken")) + case db.IsErrNameReserved(err): + ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("user.form.name_reserved", newName)) + case db.IsErrNamePatternNotAllowed(err): + ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("user.form.name_pattern_not_allowed", newName)) + case db.IsErrNameCharsNotAllowed(err): + ctx.Error(http.StatusUnprocessableEntity, "", ctx.Tr("user.form.name_chars_not_allowed", newName)) + default: + ctx.ServerError("ChangeUserName", err) + } + return + } + + log.Trace("User name changed: %s -> %s", oldName, newName) + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go new file mode 100644 index 0000000..6e4a97b --- /dev/null +++ b/routers/api/v1/api.go @@ -0,0 +1,1659 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2016 The Gitea Authors. All rights reserved. +// Copyright 2023-2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// Package v1 Gitea API +// +// This documentation describes the Gitea API. +// +// Schemes: https, http +// BasePath: /api/v1 +// Version: {{AppVer | JSEscape}} +// License: MIT http://opensource.org/licenses/MIT +// +// Consumes: +// - application/json +// - text/plain +// +// Produces: +// - application/json +// - text/html +// +// Security: +// - BasicAuth : +// - Token : +// - AccessToken : +// - AuthorizationHeaderToken : +// - SudoParam : +// - SudoHeader : +// - TOTPHeader : +// +// SecurityDefinitions: +// BasicAuth: +// type: basic +// Token: +// type: apiKey +// name: token +// in: query +// description: This authentication option is deprecated for removal in Gitea 1.23. Please use AuthorizationHeaderToken instead. +// AccessToken: +// type: apiKey +// name: access_token +// in: query +// description: This authentication option is deprecated for removal in Gitea 1.23. Please use AuthorizationHeaderToken instead. +// AuthorizationHeaderToken: +// type: apiKey +// name: Authorization +// in: header +// description: API tokens must be prepended with "token" followed by a space. +// SudoParam: +// type: apiKey +// name: sudo +// in: query +// description: Sudo API request as the user provided as the key. Admin privileges are required. +// SudoHeader: +// type: apiKey +// name: Sudo +// in: header +// description: Sudo API request as the user provided as the key. Admin privileges are required. +// TOTPHeader: +// type: apiKey +// name: X-FORGEJO-OTP +// in: header +// description: Must be used in combination with BasicAuth if two-factor authentication is enabled. +// +// swagger:meta +package v1 + +import ( + "fmt" + "net/http" + "strings" + + actions_model "code.gitea.io/gitea/models/actions" + auth_model "code.gitea.io/gitea/models/auth" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/shared" + "code.gitea.io/gitea/routers/api/v1/activitypub" + "code.gitea.io/gitea/routers/api/v1/admin" + "code.gitea.io/gitea/routers/api/v1/misc" + "code.gitea.io/gitea/routers/api/v1/notify" + "code.gitea.io/gitea/routers/api/v1/org" + "code.gitea.io/gitea/routers/api/v1/packages" + "code.gitea.io/gitea/routers/api/v1/repo" + "code.gitea.io/gitea/routers/api/v1/settings" + "code.gitea.io/gitea/routers/api/v1/user" + "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/auth" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/forms" + + _ "code.gitea.io/gitea/routers/api/v1/swagger" // for swagger generation + + "gitea.com/go-chi/binding" +) + +func sudo() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + sudo := ctx.FormString("sudo") + if len(sudo) == 0 { + sudo = ctx.Req.Header.Get("Sudo") + } + + if len(sudo) > 0 { + if ctx.IsSigned && ctx.Doer.IsAdmin { + user, err := user_model.GetUserByName(ctx, sudo) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + log.Trace("Sudo from (%s) to: %s", ctx.Doer.Name, user.Name) + ctx.Doer = user + } else { + ctx.JSON(http.StatusForbidden, map[string]string{ + "message": "Only administrators allowed to sudo.", + }) + return + } + } + } +} + +func repoAssignment() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + userName := ctx.Params("username") + repoName := ctx.Params("reponame") + + var ( + owner *user_model.User + err error + ) + + // Check if the user is the same as the repository owner. + if ctx.IsSigned && ctx.Doer.LowerName == strings.ToLower(userName) { + owner = ctx.Doer + } else { + owner, err = user_model.GetUserByName(ctx, userName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { + context.RedirectToUser(ctx.Base, userName, redirectUserID) + } else if user_model.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetUserByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + } + ctx.Repo.Owner = owner + ctx.ContextUser = owner + + // Get repository. + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + redirectRepoID, err := repo_model.LookupRedirect(ctx, owner.ID, repoName) + if err == nil { + context.RedirectToRepo(ctx.Base, redirectRepoID) + } else if repo_model.IsErrRedirectNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "LookupRepoRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByName", err) + } + return + } + + repo.Owner = owner + ctx.Repo.Repository = repo + + if ctx.Doer != nil && ctx.Doer.ID == user_model.ActionsUserID { + taskID := ctx.Data["ActionsTaskID"].(int64) + task, err := actions_model.GetTaskByID(ctx, taskID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "actions_model.GetTaskByID", err) + return + } + if task.RepoID != repo.ID { + ctx.NotFound() + return + } + + if task.IsForkPullRequest { + ctx.Repo.Permission.AccessMode = perm.AccessModeRead + } else { + ctx.Repo.Permission.AccessMode = perm.AccessModeWrite + } + + if err := ctx.Repo.Repository.LoadUnits(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadUnits", err) + return + } + ctx.Repo.Permission.Units = ctx.Repo.Repository.Units + ctx.Repo.Permission.UnitsMode = make(map[unit.Type]perm.AccessMode) + for _, u := range ctx.Repo.Repository.Units { + ctx.Repo.Permission.UnitsMode[u.Type] = ctx.Repo.Permission.AccessMode + } + } else { + ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + } + + if !ctx.Repo.HasAccess() { + ctx.NotFound() + return + } + } +} + +// must be used within a group with a call to repoAssignment() to set ctx.Repo +func commentAssignment(idParam string) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64(idParam)) + if err != nil { + if issues_model.IsErrCommentNotExist(err) { + ctx.NotFound(err) + } else { + ctx.InternalServerError(err) + } + return + } + + if err = comment.LoadIssue(ctx); err != nil { + ctx.InternalServerError(err) + return + } + if comment.Issue == nil || comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return + } + + if !ctx.Repo.CanReadIssuesOrPulls(comment.Issue.IsPull) { + ctx.NotFound() + return + } + + comment.Issue.Repo = ctx.Repo.Repository + + ctx.Comment = comment + } +} + +func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.Package.AccessMode < accessMode && !ctx.IsUserSiteAdmin() { + ctx.Error(http.StatusForbidden, "reqPackageAccess", "user should have specific permission or be a site admin") + return + } + } +} + +func checkTokenPublicOnly() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.PublicOnly { + return + } + + requiredScopeCategories, ok := ctx.Data["requiredScopeCategories"].([]auth_model.AccessTokenScopeCategory) + if !ok || len(requiredScopeCategories) == 0 { + return + } + + // public Only permission check + switch { + case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository): + if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public repos") + return + } + case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue): + if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public issues") + return + } + case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization): + if ctx.Org.Organization != nil && ctx.Org.Organization.Visibility != api.VisibleTypePublic { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs") + return + } + if ctx.ContextUser != nil && ctx.ContextUser.IsOrganization() && ctx.ContextUser.Visibility != api.VisibleTypePublic { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs") + return + } + case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser): + if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public users") + return + } + case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub): + if ctx.ContextUser != nil && ctx.ContextUser.IsUser() && ctx.ContextUser.Visibility != api.VisibleTypePublic { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public activitypub") + return + } + case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification): + if ctx.Repo.Repository != nil && ctx.Repo.Repository.IsPrivate { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public notifications") + return + } + case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage): + if ctx.Package != nil && ctx.Package.Owner.Visibility.IsPrivate() { + ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public packages") + return + } + } + } +} + +// if a token is being used for auth, we check that it contains the required scope +// if a token is not being used, reqToken will enforce other sign in methods +func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + // no scope required + if len(requiredScopeCategories) == 0 { + return + } + + // Need OAuth2 token to be present. + scope, scopeExists := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope) + if ctx.Data["IsApiToken"] != true || !scopeExists { + return + } + + // use the http method to determine the access level + requiredScopeLevel := auth_model.Read + if ctx.Req.Method == "POST" || ctx.Req.Method == "PUT" || ctx.Req.Method == "PATCH" || ctx.Req.Method == "DELETE" { + requiredScopeLevel = auth_model.Write + } + + // get the required scope for the given access level and category + requiredScopes := auth_model.GetRequiredScopes(requiredScopeLevel, requiredScopeCategories...) + allow, err := scope.HasScope(requiredScopes...) + if err != nil { + ctx.Error(http.StatusForbidden, "tokenRequiresScope", "checking scope failed: "+err.Error()) + return + } + + if !allow { + ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes)) + return + } + + ctx.Data["requiredScopeCategories"] = requiredScopeCategories + + // check if scope only applies to public resources + publicOnly, err := scope.PublicOnly() + if err != nil { + ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error()) + return + } + + // assign to true so that those searching should only filter public repositories/users/organizations + ctx.PublicOnly = publicOnly + } +} + +// Contexter middleware already checks token for user sign in process. +func reqToken() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + // If actions token is present + if true == ctx.Data["IsActionsToken"] { + return + } + + if ctx.IsSigned { + return + } + ctx.Error(http.StatusUnauthorized, "reqToken", "token is required") + } +} + +func reqExploreSignIn() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if setting.Service.Explore.RequireSigninView && !ctx.IsSigned { + ctx.Error(http.StatusUnauthorized, "reqExploreSignIn", "you must be signed in to search for users") + } + } +} + +func reqBasicOrRevProxyAuth() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.IsSigned && setting.Service.EnableReverseProxyAuthAPI && ctx.Data["AuthedMethod"].(string) == auth.ReverseProxyMethodName { + return + } + if !ctx.IsBasicAuth { + ctx.Error(http.StatusUnauthorized, "reqBasicAuth", "auth required") + return + } + } +} + +// reqSiteAdmin user should be the site admin +func reqSiteAdmin() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.IsUserSiteAdmin() { + ctx.Error(http.StatusForbidden, "reqSiteAdmin", "user should be the site admin") + return + } + } +} + +// reqOwner user should be the owner of the repo or site admin. +func reqOwner() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.Repo.IsOwner() && !ctx.IsUserSiteAdmin() { + ctx.Error(http.StatusForbidden, "reqOwner", "user should be the owner of the repo") + return + } + } +} + +// reqSelfOrAdmin doer should be the same as the contextUser or site admin +func reqSelfOrAdmin() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.IsUserSiteAdmin() && ctx.ContextUser != ctx.Doer { + ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser") + return + } + } +} + +// reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin +func reqAdmin() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { + ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository") + return + } + } +} + +// reqRepoWriter user should have a permission to write to a repo, or be a site admin +func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.IsUserRepoWriter(unitTypes) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { + ctx.Error(http.StatusForbidden, "reqRepoWriter", "user should have a permission to write to a repo") + return + } + } +} + +// 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.Error(http.StatusForbidden, "reqRepoBranchWriter", "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) { + if !ctx.Repo.CanRead(unitType) && !ctx.IsUserRepoAdmin() && !ctx.IsUserSiteAdmin() { + ctx.Error(http.StatusForbidden, "reqRepoReader", "user should have specific read permission or be a repo admin or a site admin") + return + } + } +} + +// reqAnyRepoReader user should have any permission to read repository or permissions of site admin +func reqAnyRepoReader() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.Repo.HasAccess() && !ctx.IsUserSiteAdmin() { + ctx.Error(http.StatusForbidden, "reqAnyRepoReader", "user should have any permission to read repository or permissions of site admin") + return + } + } +} + +// reqOrgOwnership user should be an organization owner, or a site admin +func reqOrgOwnership() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.IsUserSiteAdmin() { + return + } + + var orgID int64 + if ctx.Org.Organization != nil { + orgID = ctx.Org.Organization.ID + } else if ctx.Org.Team != nil { + orgID = ctx.Org.Team.OrgID + } else { + ctx.Error(http.StatusInternalServerError, "", "reqOrgOwnership: unprepared context") + return + } + + isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrganizationOwner", err) + return + } else if !isOwner { + if ctx.Org.Organization != nil { + ctx.Error(http.StatusForbidden, "", "Must be an organization owner") + } else { + ctx.NotFound() + } + return + } + } +} + +// reqTeamMembership user should be an team member, or a site admin +func reqTeamMembership() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.IsUserSiteAdmin() { + return + } + if ctx.Org.Team == nil { + ctx.Error(http.StatusInternalServerError, "", "reqTeamMembership: unprepared context") + return + } + + orgID := ctx.Org.Team.OrgID + isOwner, err := organization.IsOrganizationOwner(ctx, orgID, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrganizationOwner", err) + return + } else if isOwner { + return + } + + if isTeamMember, err := organization.IsTeamMember(ctx, orgID, ctx.Org.Team.ID, ctx.Doer.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "IsTeamMember", err) + return + } else if !isTeamMember { + isOrgMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err) + } else if isOrgMember { + ctx.Error(http.StatusForbidden, "", "Must be a team member") + } else { + ctx.NotFound() + } + return + } + } +} + +// reqOrgMembership user should be an organization member, or a site admin +func reqOrgMembership() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if ctx.IsUserSiteAdmin() { + return + } + + var orgID int64 + if ctx.Org.Organization != nil { + orgID = ctx.Org.Organization.ID + } else if ctx.Org.Team != nil { + orgID = ctx.Org.Team.OrgID + } else { + ctx.Error(http.StatusInternalServerError, "", "reqOrgMembership: unprepared context") + return + } + + if isMember, err := organization.IsOrganizationMember(ctx, orgID, ctx.Doer.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err) + return + } else if !isMember { + if ctx.Org.Organization != nil { + ctx.Error(http.StatusForbidden, "", "Must be an organization member") + } else { + ctx.NotFound() + } + return + } + } +} + +func reqGitHook() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if !ctx.Doer.CanEditGitHook() { + ctx.Error(http.StatusForbidden, "", "must be allowed to edit Git hooks") + return + } + } +} + +// reqWebhooksEnabled requires webhooks to be enabled by admin. +func reqWebhooksEnabled() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + if setting.DisableWebhooks { + ctx.Error(http.StatusForbidden, "", "webhooks disabled by administrator") + return + } + } +} + +func orgAssignment(args ...bool) func(ctx *context.APIContext) { + var ( + assignOrg bool + assignTeam bool + ) + if len(args) > 0 { + assignOrg = args[0] + } + if len(args) > 1 { + assignTeam = args[1] + } + return func(ctx *context.APIContext) { + ctx.Org = new(context.APIOrganization) + + var err error + if assignOrg { + ctx.Org.Organization, err = organization.GetOrgByName(ctx, ctx.Params(":org")) + if err != nil { + if organization.IsErrOrgNotExist(err) { + redirectUserID, err := user_model.LookupUserRedirect(ctx, ctx.Params(":org")) + if err == nil { + context.RedirectToUser(ctx.Base, ctx.Params(":org"), redirectUserID) + } else if user_model.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetOrgByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "LookupUserRedirect", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) + } + return + } + ctx.ContextUser = ctx.Org.Organization.AsUser() + } + + if assignTeam { + ctx.Org.Team, err = organization.GetTeamByID(ctx, ctx.ParamsInt64(":teamid")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetTeamById", err) + } + return + } + } + } +} + +func mustEnableIssues(ctx *context.APIContext) { + if !ctx.Repo.CanRead(unit.TypeIssues) { + if log.IsTrace() { + if ctx.IsSigned { + log.Trace("Permission Denied: User %-v cannot read %-v in Repo %-v\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + unit.TypeIssues, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Anonymous user cannot read %-v in Repo %-v\n"+ + "Anonymous user in Repo has Permissions: %-+v", + unit.TypeIssues, + ctx.Repo.Repository, + ctx.Repo.Permission) + } + } + ctx.NotFound() + return + } +} + +func mustAllowPulls(ctx *context.APIContext) { + if !(ctx.Repo.Repository.CanEnablePulls() && ctx.Repo.CanRead(unit.TypePullRequests)) { + if ctx.Repo.Repository.CanEnablePulls() && log.IsTrace() { + if ctx.IsSigned { + log.Trace("Permission Denied: User %-v cannot read %-v in Repo %-v\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + unit.TypePullRequests, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Anonymous user cannot read %-v in Repo %-v\n"+ + "Anonymous user in Repo has Permissions: %-+v", + unit.TypePullRequests, + ctx.Repo.Repository, + ctx.Repo.Permission) + } + } + ctx.NotFound() + return + } +} + +func mustEnableIssuesOrPulls(ctx *context.APIContext) { + if !ctx.Repo.CanRead(unit.TypeIssues) && + !(ctx.Repo.Repository.CanEnablePulls() && ctx.Repo.CanRead(unit.TypePullRequests)) { + if ctx.Repo.Repository.CanEnablePulls() && log.IsTrace() { + if ctx.IsSigned { + log.Trace("Permission Denied: User %-v cannot read %-v and %-v in Repo %-v\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + unit.TypeIssues, + unit.TypePullRequests, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Anonymous user cannot read %-v and %-v in Repo %-v\n"+ + "Anonymous user in Repo has Permissions: %-+v", + unit.TypeIssues, + unit.TypePullRequests, + ctx.Repo.Repository, + ctx.Repo.Permission) + } + } + ctx.NotFound() + return + } +} + +func mustEnableWiki(ctx *context.APIContext) { + if !(ctx.Repo.CanRead(unit.TypeWiki)) { + ctx.NotFound() + return + } +} + +func mustNotBeArchived(ctx *context.APIContext) { + if ctx.Repo.Repository.IsArchived { + ctx.Error(http.StatusLocked, "RepoArchived", fmt.Errorf("%s is archived", ctx.Repo.Repository.LogString())) + return + } +} + +func mustEnableAttachments(ctx *context.APIContext) { + if !setting.Attachment.Enabled { + ctx.NotFound() + return + } +} + +// bind binding an obj to a func(ctx *context.APIContext) +func bind[T any](_ T) any { + return func(ctx *context.APIContext) { + theObj := new(T) // create a new form obj for every request but not use obj directly + errs := binding.Bind(ctx.Req, theObj) + if len(errs) > 0 { + ctx.Error(http.StatusUnprocessableEntity, "validationError", fmt.Sprintf("%s: %s", errs[0].FieldNames, errs[0].Error())) + return + } + web.SetForm(ctx, theObj) + } +} + +func individualPermsChecker(ctx *context.APIContext) { + // org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked. + if ctx.ContextUser.IsIndividual() { + switch { + case ctx.ContextUser.Visibility == api.VisibleTypePrivate: + if ctx.Doer == nil || (ctx.ContextUser.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin) { + ctx.NotFound("Visit Project", nil) + return + } + case ctx.ContextUser.Visibility == api.VisibleTypeLimited: + if ctx.Doer == nil { + ctx.NotFound("Visit Project", nil) + return + } + } + } +} + +// Routes registers all v1 APIs routes to web application. +func Routes() *web.Route { + m := web.NewRoute() + + m.Use(shared.Middlewares()...) + + addActionsRoutes := func( + m *web.Route, + reqChecker func(ctx *context.APIContext), + act actions.API, + ) { + m.Group("/actions", func() { + m.Group("/secrets", func() { + m.Get("", reqToken(), reqChecker, act.ListActionsSecrets) + m.Combo("/{secretname}"). + Put(reqToken(), reqChecker, bind(api.CreateOrUpdateSecretOption{}), act.CreateOrUpdateSecret). + Delete(reqToken(), reqChecker, act.DeleteSecret) + }) + + m.Group("/variables", func() { + m.Get("", reqToken(), reqChecker, act.ListVariables) + m.Combo("/{variablename}"). + Get(reqToken(), reqChecker, act.GetVariable). + Delete(reqToken(), reqChecker, act.DeleteVariable). + Post(reqToken(), reqChecker, bind(api.CreateVariableOption{}), act.CreateVariable). + Put(reqToken(), reqChecker, bind(api.UpdateVariableOption{}), act.UpdateVariable) + }) + + m.Group("/runners", func() { + m.Get("/registration-token", reqToken(), reqChecker, act.GetRegistrationToken) + }) + }) + } + + m.Group("", func() { + // Miscellaneous (no scope required) + if setting.API.EnableSwagger { + m.Get("/swagger", func(ctx *context.APIContext) { + ctx.Redirect(setting.AppSubURL + "/api/swagger") + }) + } + + if setting.Federation.Enabled { + m.Get("/nodeinfo", misc.NodeInfo) + m.Group("/activitypub", func() { + // deprecated, remove in 1.20, use /user-id/{user-id} instead + m.Group("/user/{username}", func() { + m.Get("", activitypub.Person) + m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) + }, context.UserAssignmentAPI(), checkTokenPublicOnly()) + m.Group("/user-id/{user-id}", func() { + m.Get("", activitypub.Person) + m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) + }, context.UserIDAssignmentAPI(), checkTokenPublicOnly()) + m.Group("/actor", func() { + m.Get("", activitypub.Actor) + m.Post("/inbox", activitypub.ActorInbox) + }) + m.Group("/repository-id/{repository-id}", func() { + m.Get("", activitypub.Repository) + m.Post("/inbox", + bind(forgefed.ForgeLike{}), + // TODO: activitypub.ReqHTTPSignature(), + activitypub.RepositoryInbox) + }, context.RepositoryIDAssignmentAPI()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) + } + + // Misc (public accessible) + m.Group("", func() { + m.Get("/version", misc.Version) + m.Get("/signing-key.gpg", misc.SigningKey) + 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) + m.Get("/gitignore/templates", misc.ListGitignoresTemplates) + m.Get("/gitignore/templates/{name}", misc.GetGitignoreTemplateInfo) + m.Get("/licenses", misc.ListLicenseTemplates) + m.Get("/licenses/{name}", misc.GetLicenseTemplateInfo) + m.Get("/label/templates", misc.ListLabelTemplates) + m.Get("/label/templates/{name}", misc.GetLabelTemplate) + + m.Group("/settings", func() { + m.Get("/ui", settings.GetGeneralUISettings) + m.Get("/api", settings.GetGeneralAPISettings) + m.Get("/attachment", settings.GetGeneralAttachmentSettings) + m.Get("/repository", settings.GetGeneralRepoSettings) + }) + }) + + // Notifications (requires 'notifications' scope) + m.Group("/notifications", func() { + m.Combo(""). + Get(reqToken(), notify.ListNotifications). + Put(reqToken(), notify.ReadNotifications) + m.Get("/new", reqToken(), notify.NewAvailable) + m.Combo("/threads/{id}"). + Get(reqToken(), notify.GetThread). + Patch(reqToken(), notify.ReadThread) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) + + // Users (requires user scope) + m.Group("/users", func() { + m.Get("/search", reqExploreSignIn(), user.Search) + + m.Group("/{username}", func() { + m.Get("", reqExploreSignIn(), user.GetInfo) + + if setting.Service.EnableUserHeatmap { + m.Get("/heatmap", user.GetUserHeatmapData) + } + + m.Get("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository), reqExploreSignIn(), user.ListUserRepos) + m.Group("/tokens", func() { + m.Combo("").Get(user.ListAccessTokens). + Post(bind(api.CreateAccessTokenOption{}), reqToken(), user.CreateAccessToken) + m.Combo("/{id}").Delete(reqToken(), user.DeleteAccessToken) + }, reqSelfOrAdmin(), reqBasicOrRevProxyAuth()) + + m.Get("/activities/feeds", user.ListUserActivityFeeds) + }, context.UserAssignmentAPI(), checkTokenPublicOnly(), individualPermsChecker) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser)) + + // Users (requires user scope) + m.Group("/users", func() { + m.Group("/{username}", func() { + m.Get("/keys", user.ListPublicKeys) + m.Get("/gpg_keys", user.ListGPGKeys) + + m.Get("/followers", user.ListFollowers) + m.Group("/following", func() { + m.Get("", user.ListFollowing) + m.Get("/{target}", user.CheckFollowing) + }) + + if !setting.Repository.DisableStars { + m.Get("/starred", user.GetStarredRepos) + } + + m.Get("/subscriptions", user.GetWatchedRepos) + }, context.UserAssignmentAPI(), checkTokenPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + + // Users (requires user scope) + m.Group("/user", func() { + m.Get("", user.GetAuthenticatedUser) + if setting.Quota.Enabled { + m.Group("/quota", func() { + m.Get("", user.GetQuota) + m.Get("/check", user.CheckQuota) + m.Get("/attachments", user.ListQuotaAttachments) + m.Get("/packages", user.ListQuotaPackages) + m.Get("/artifacts", user.ListQuotaArtifacts) + }) + } + m.Group("/settings", func() { + m.Get("", user.GetUserSettings) + m.Patch("", bind(api.UserSettingsOptions{}), user.UpdateUserSettings) + }, reqToken()) + m.Combo("/emails"). + Get(user.ListEmails). + Post(bind(api.CreateEmailOption{}), user.AddEmail). + Delete(bind(api.DeleteEmailOption{}), user.DeleteEmail) + + // manage user-level actions features + m.Group("/actions", func() { + m.Group("/secrets", func() { + m.Combo("/{secretname}"). + Put(bind(api.CreateOrUpdateSecretOption{}), user.CreateOrUpdateSecret). + Delete(user.DeleteSecret) + }) + + m.Group("/variables", func() { + m.Get("", user.ListVariables) + m.Combo("/{variablename}"). + Get(user.GetVariable). + Delete(user.DeleteVariable). + Post(bind(api.CreateVariableOption{}), user.CreateVariable). + Put(bind(api.UpdateVariableOption{}), user.UpdateVariable) + }) + + m.Group("/runners", func() { + m.Get("/registration-token", reqToken(), user.GetRegistrationToken) + }) + }) + + m.Get("/followers", user.ListMyFollowers) + m.Group("/following", func() { + m.Get("", user.ListMyFollowing) + m.Group("/{username}", func() { + m.Get("", user.CheckMyFollowing) + m.Put("", user.Follow) + m.Delete("", user.Unfollow) + }, context.UserAssignmentAPI()) + }) + + // (admin:public_key scope) + m.Group("/keys", func() { + m.Combo("").Get(user.ListMyPublicKeys). + Post(bind(api.CreateKeyOption{}), user.CreatePublicKey) + m.Combo("/{id}").Get(user.GetPublicKey). + Delete(user.DeletePublicKey) + }) + + // (admin:application scope) + m.Group("/applications", func() { + m.Combo("/oauth2"). + Get(user.ListOauth2Applications). + Post(bind(api.CreateOAuth2ApplicationOptions{}), user.CreateOauth2Application) + m.Combo("/oauth2/{id}"). + Delete(user.DeleteOauth2Application). + Patch(bind(api.CreateOAuth2ApplicationOptions{}), user.UpdateOauth2Application). + Get(user.GetOauth2Application) + }) + + // (admin:gpg_key scope) + m.Group("/gpg_keys", func() { + m.Combo("").Get(user.ListMyGPGKeys). + Post(bind(api.CreateGPGKeyOption{}), user.CreateGPGKey) + m.Combo("/{id}").Get(user.GetGPGKey). + Delete(user.DeleteGPGKey) + }) + m.Get("/gpg_key_token", user.GetVerificationToken) + m.Post("/gpg_key_verify", bind(api.VerifyGPGKeyOption{}), user.VerifyUserGPGKey) + + // (repo scope) + m.Combo("/repos", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(user.ListMyRepos). + Post(bind(api.CreateRepoOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetUser), repo.Create) + + // (repo scope) + if !setting.Repository.DisableStars { + m.Group("/starred", func() { + m.Get("", user.GetMyStarredRepos) + m.Group("/{username}/{reponame}", func() { + m.Get("", user.IsStarring) + m.Put("", user.Star) + m.Delete("", user.Unstar) + }, repoAssignment(), checkTokenPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) + } + m.Get("/times", repo.ListMyTrackedTimes) + m.Get("/stopwatches", repo.GetStopwatches) + m.Get("/subscriptions", user.GetMyWatchedRepos) + m.Get("/teams", org.ListUserTeams) + m.Group("/hooks", func() { + m.Combo("").Get(user.ListHooks). + Post(bind(api.CreateHookOption{}), user.CreateHook) + m.Combo("/{id}").Get(user.GetHook). + Patch(bind(api.EditHookOption{}), user.EditHook). + Delete(user.DeleteHook) + }, reqWebhooksEnabled()) + + m.Group("", func() { + m.Get("/list_blocked", user.ListBlockedUsers) + m.Group("", func() { + m.Put("/block/{username}", user.BlockUser) + m.Put("/unblock/{username}", user.UnblockUser) + }, context.UserAssignmentAPI()) + }) + + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) + m.Delete("", user.DeleteAvatar) + }, reqToken()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) + + // Repositories (requires repo scope, org scope) + m.Post("/org/{org}/repos", + // FIXME: we need org in context + tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization, auth_model.AccessTokenScopeCategoryRepository), + reqToken(), + bind(api.CreateRepoOption{}), + repo.CreateOrgRepoDeprecated) + + // requires repo scope + // FIXME: Don't expose repository id outside of the system + m.Combo("/repositories/{id}", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)).Get(repo.GetByID) + + // Repos (requires repo scope) + m.Group("/repos", func() { + m.Get("/search", repo.Search) + + // (repo scope) + m.Post("/migrate", reqToken(), bind(api.MigrateRepoOptions{}), repo.Migrate) + + m.Group("/{username}/{reponame}", func() { + m.Get("/compare/*", reqRepoReader(unit.TypeCode), repo.CompareDiff) + + m.Combo("").Get(reqAnyRepoReader(), repo.Get). + Delete(reqToken(), reqOwner(), repo.Delete). + Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), repo.Edit) + m.Post("/generate", reqToken(), reqRepoReader(unit.TypeCode), bind(api.GenerateRepoOption{}), repo.Generate) + m.Group("/transfer", func() { + m.Post("", reqOwner(), bind(api.TransferRepoOption{}), repo.Transfer) + m.Post("/accept", repo.AcceptTransfer) + m.Post("/reject", repo.RejectTransfer) + }, reqToken()) + addActionsRoutes( + m, + reqOwner(), + repo.NewAction(), + ) + m.Group("/hooks/git", func() { + m.Combo("").Get(repo.ListGitHooks) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetGitHook). + Patch(bind(api.EditGitHookOption{}), repo.EditGitHook). + Delete(repo.DeleteGitHook) + }) + }, reqToken(), reqAdmin(), reqGitHook(), context.ReferencesGitRepo(true)) + m.Group("/hooks", func() { + m.Combo("").Get(repo.ListHooks). + Post(bind(api.CreateHookOption{}), repo.CreateHook) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetHook). + Patch(bind(api.EditHookOption{}), repo.EditHook). + Delete(repo.DeleteHook) + m.Post("/tests", context.ReferencesGitRepo(), context.RepoRefForAPI, repo.TestHook) + }) + }, reqToken(), reqAdmin(), reqWebhooksEnabled()) + m.Group("/collaborators", func() { + m.Get("", reqAnyRepoReader(), repo.ListCollaborators) + m.Group("/{collaborator}", func() { + m.Combo("").Get(reqAnyRepoReader(), repo.IsCollaborator). + Put(reqAdmin(), bind(api.AddCollaboratorOption{}), repo.AddCollaborator). + Delete(reqAdmin(), repo.DeleteCollaborator) + m.Get("/permission", repo.GetRepoPermissions) + }) + }, reqToken()) + if setting.Repository.EnableFlags { + m.Group("/flags", func() { + m.Combo("").Get(repo.ListFlags). + Put(bind(api.ReplaceFlagsOption{}), repo.ReplaceAllFlags). + Delete(repo.DeleteAllFlags) + m.Group("/{flag}", func() { + m.Combo("").Get(repo.HasFlag). + Put(repo.AddFlag). + Delete(repo.DeleteFlag) + }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) + } + m.Get("/assignees", reqToken(), reqAnyRepoReader(), repo.GetAssignees) + m.Get("/reviewers", reqToken(), reqAnyRepoReader(), repo.GetReviewers) + m.Group("/teams", func() { + m.Get("", reqAnyRepoReader(), repo.ListTeams) + m.Combo("/{team}").Get(reqAnyRepoReader(), repo.IsTeam). + Put(reqAdmin(), repo.AddTeam). + Delete(reqAdmin(), repo.DeleteTeam) + }, 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) + if !setting.Repository.DisableForks { + m.Combo("/forks").Get(repo.ListForks). + Post(reqToken(), reqRepoReader(unit.TypeCode), bind(api.CreateForkOption{}), repo.CreateFork) + } + m.Group("/branches", func() { + m.Get("", repo.ListBranches) + m.Get("/*", repo.GetBranch) + m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteBranch) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateBranchRepoOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.CreateBranch) + }, context.ReferencesGitRepo(), reqRepoReader(unit.TypeCode)) + m.Group("/branch_protections", func() { + m.Get("", repo.ListBranchProtections) + m.Post("", bind(api.CreateBranchProtectionOption{}), mustNotBeArchived, repo.CreateBranchProtection) + m.Group("/{name}", func() { + m.Get("", repo.GetBranchProtection) + m.Patch("", bind(api.EditBranchProtectionOption{}), mustNotBeArchived, repo.EditBranchProtection) + m.Delete("", repo.DeleteBranchProtection) + }) + }, reqToken(), reqAdmin()) + m.Group("/tags", func() { + m.Get("", repo.ListTags) + m.Get("/*", repo.GetTag) + m.Post("", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, bind(api.CreateTagOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.CreateTag) + m.Delete("/*", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.DeleteTag) + }, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true)) + m.Group("/tag_protections", func() { + m.Combo("").Get(repo.ListTagProtection). + Post(bind(api.CreateTagProtectionOption{}), mustNotBeArchived, repo.CreateTagProtection) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetTagProtection). + Patch(bind(api.EditTagProtectionOption{}), mustNotBeArchived, repo.EditTagProtection). + Delete(repo.DeleteTagProtection) + }) + }, reqToken(), reqAdmin()) + m.Group("/actions", func() { + m.Get("/tasks", repo.ListActionTasks) + + m.Group("/workflows", func() { + m.Group("/{workflowname}", func() { + m.Post("/dispatches", reqToken(), reqRepoWriter(unit.TypeActions), mustNotBeArchived, bind(api.DispatchWorkflowOption{}), repo.DispatchWorkflow) + }) + }) + }, reqRepoReader(unit.TypeActions), context.ReferencesGitRepo(true)) + m.Group("/keys", func() { + m.Combo("").Get(repo.ListDeployKeys). + Post(bind(api.CreateKeyOption{}), repo.CreateDeployKey) + m.Combo("/{id}").Get(repo.GetDeployKey). + Delete(repo.DeleteDeploykey) + }, reqToken(), reqAdmin()) + m.Group("/times", func() { + m.Combo("").Get(repo.ListTrackedTimesByRepository) + m.Combo("/{timetrackingusername}").Get(repo.ListTrackedTimesByUser) + }, mustEnableIssues, reqToken()) + m.Group("/wiki", func() { + m.Combo("/page/{pageName}"). + Get(repo.GetWikiPage). + Patch(mustNotBeArchived, reqToken(), reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.EditWikiPage). + Delete(mustNotBeArchived, reqToken(), reqRepoWriter(unit.TypeWiki), repo.DeleteWikiPage) + m.Get("/revisions/{pageName}", repo.ListPageRevisions) + m.Post("/new", reqToken(), mustNotBeArchived, reqRepoWriter(unit.TypeWiki), bind(api.CreateWikiPageOptions{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeWiki, context.QuotaTargetRepo), repo.NewWikiPage) + m.Get("/pages", repo.ListWikiPages) + }, mustEnableWiki) + 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) + if !setting.Repository.DisableStars { + m.Get("/stargazers", repo.ListStargazers) + } + m.Get("/subscribers", repo.ListSubscribers) + m.Group("/subscription", func() { + m.Get("", user.IsWatching) + m.Put("", user.Watch) + m.Delete("", user.Unwatch) + }, reqToken()) + m.Group("/releases", func() { + m.Combo("").Get(repo.ListReleases). + Post(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.CreateReleaseOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.CreateRelease) + m.Combo("/latest").Get(repo.GetLatestRelease) + m.Group("/{id}", func() { + m.Combo("").Get(repo.GetRelease). + Patch(reqToken(), reqRepoWriter(unit.TypeReleases), context.ReferencesGitRepo(), bind(api.EditReleaseOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.EditRelease). + Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteRelease) + m.Group("/assets", func() { + m.Combo("").Get(repo.ListReleaseAttachments). + Post(reqToken(), reqRepoWriter(unit.TypeReleases), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeAssetsAttachmentsReleases, context.QuotaTargetRepo), repo.CreateReleaseAttachment) + m.Combo("/{attachment_id}").Get(repo.GetReleaseAttachment). + Patch(reqToken(), reqRepoWriter(unit.TypeReleases), bind(api.EditAttachmentOptions{}), repo.EditReleaseAttachment). + Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseAttachment) + }) + }) + m.Group("/tags", func() { + m.Combo("/{tag}"). + Get(repo.GetReleaseByTag). + Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag) + }) + }, reqRepoReader(unit.TypeReleases)) + m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.MirrorSync) + m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync) + m.Group("/push_mirrors", func() { + m.Combo("").Get(repo.ListPushMirrors). + Post(mustNotBeArchived, bind(api.CreatePushMirrorOption{}), repo.AddPushMirror) + m.Combo("/{name}"). + Delete(mustNotBeArchived, repo.DeletePushMirrorByRemoteName). + Get(repo.GetPushMirrorByName) + }, reqAdmin(), reqToken()) + + m.Get("/editorconfig/{filename}", context.ReferencesGitRepo(), context.RepoRefForAPI, reqRepoReader(unit.TypeCode), repo.GetEditorconfig) + m.Group("/pulls", func() { + m.Combo("").Get(repo.ListPullRequests). + Post(reqToken(), mustNotBeArchived, bind(api.CreatePullRequestOption{}), repo.CreatePullRequest) + m.Get("/pinned", repo.ListPinnedPullRequests) + m.Group("/{index}", func() { + m.Combo("").Get(repo.GetPullRequest). + Patch(reqToken(), bind(api.EditPullRequestOption{}), repo.EditPullRequest) + m.Get(".{diffType:diff|patch}", repo.DownloadPullDiffOrPatch) + m.Post("/update", reqToken(), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.UpdatePullRequest) + m.Get("/commits", repo.GetPullRequestCommits) + m.Get("/files", repo.GetPullRequestFiles) + m.Combo("/merge").Get(repo.IsPullRequestMerged). + Post(reqToken(), mustNotBeArchived, bind(forms.MergePullRequestForm{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeGitAll, context.QuotaTargetRepo), repo.MergePullRequest). + Delete(reqToken(), mustNotBeArchived, repo.CancelScheduledAutoMerge) + m.Group("/reviews", func() { + m.Combo(""). + Get(repo.ListPullReviews). + Post(reqToken(), bind(api.CreatePullReviewOptions{}), repo.CreatePullReview) + m.Group("/{id}", func() { + m.Combo(""). + Get(repo.GetPullReview). + Delete(reqToken(), repo.DeletePullReview). + Post(reqToken(), bind(api.SubmitPullReviewOptions{}), repo.SubmitPullReview) + m.Group("/comments", func() { + m.Combo(""). + Get(repo.GetPullReviewComments). + Post(reqToken(), bind(api.CreatePullReviewCommentOptions{}), repo.CreatePullReviewComment) + m.Group("/{comment}", func() { + m.Combo(""). + Get(repo.GetPullReviewComment). + Delete(reqToken(), repo.DeletePullReviewComment) + }, commentAssignment("comment")) + }) + m.Post("/dismissals", reqToken(), bind(api.DismissPullReviewOptions{}), repo.DismissPullReview) + m.Post("/undismissals", reqToken(), repo.UnDismissPullReview) + }) + }) + m.Combo("/requested_reviewers", reqToken()). + Delete(bind(api.PullReviewRequestOptions{}), repo.DeleteReviewRequests). + Post(bind(api.PullReviewRequestOptions{}), repo.CreateReviewRequests) + }) + m.Get("/{base}/*", repo.GetPullRequestByBaseHead) + }, mustAllowPulls, reqRepoReader(unit.TypeCode), context.ReferencesGitRepo()) + m.Group("/statuses", func() { + m.Combo("/{sha}").Get(repo.GetCommitStatuses). + Post(reqToken(), reqRepoWriter(unit.TypeCode), bind(api.CreateStatusOption{}), repo.NewCommitStatus) + }, reqRepoReader(unit.TypeCode)) + m.Group("/commits", func() { + m.Get("", context.ReferencesGitRepo(), repo.GetAllCommits) + m.Group("/{ref}", func() { + m.Get("/status", repo.GetCombinedCommitStatusByRef) + m.Get("/statuses", repo.GetCommitStatusesByRef) + m.Get("/pull", repo.GetCommitPullRequest) + }, context.ReferencesGitRepo()) + }, reqRepoReader(unit.TypeCode)) + m.Group("/git", func() { + m.Group("/commits", func() { + m.Get("/{sha}", repo.GetSingleCommit) + m.Get("/{sha}.{diffType:diff|patch}", repo.DownloadCommitDiffOrPatch) + }) + m.Get("/refs", repo.GetGitAllRefs) + m.Get("/refs/*", repo.GetGitRefs) + m.Get("/trees/{sha}", repo.GetTree) + m.Get("/blobs/{sha}", repo.GetBlob) + 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, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.ApplyDiffPatch) + m.Group("/contents", func() { + m.Get("", repo.GetContentsList) + m.Post("", reqToken(), bind(api.ChangeFilesOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.ChangeFiles) + m.Get("/*", repo.GetContents) + m.Group("/*", func() { + m.Post("", bind(api.CreateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.CreateFile) + m.Put("", bind(api.UpdateFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.UpdateFile) + m.Delete("", bind(api.DeleteFileOptions{}), reqRepoBranchWriter, mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetRepo), repo.DeleteFile) + }, reqToken()) + }, reqRepoReader(unit.TypeCode)) + m.Get("/signing-key.gpg", misc.SigningKey) + m.Group("/topics", func() { + m.Combo("").Get(repo.ListTopics). + Put(reqToken(), reqAdmin(), bind(api.RepoTopicOptions{}), repo.UpdateTopics) + m.Group("/{topic}", func() { + m.Combo("").Put(reqToken(), repo.AddTopic). + Delete(reqToken(), repo.DeleteTopic) + }, reqAdmin()) + }, reqAnyRepoReader()) + m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates) + m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig) + m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig) + m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages) + m.Get("/activities/feeds", repo.ListRepoActivityFeeds) + m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed) + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) + m.Delete("", repo.DeleteAvatar) + }, reqAdmin(), reqToken()) + }, repoAssignment(), checkTokenPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) + + // Notifications (requires notifications scope) + m.Group("/repos", func() { + m.Group("/{username}/{reponame}", func() { + m.Combo("/notifications", reqToken()). + Get(notify.ListRepoNotifications). + Put(notify.ReadRepoNotifications) + }, repoAssignment(), checkTokenPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryNotification)) + + // Issue (requires issue scope) + m.Group("/repos", func() { + m.Get("/issues/search", repo.SearchIssues) + + m.Group("/{username}/{reponame}", func() { + m.Group("/issues", func() { + m.Combo("").Get(repo.ListIssues). + Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), reqRepoReader(unit.TypeIssues), repo.CreateIssue) + m.Get("/pinned", reqRepoReader(unit.TypeIssues), repo.ListPinnedIssues) + m.Group("/comments", func() { + m.Get("", repo.ListRepoIssueComments) + m.Group("/{id}", func() { + m.Combo(""). + Get(repo.GetIssueComment). + Patch(mustNotBeArchived, reqToken(), bind(api.EditIssueCommentOption{}), repo.EditIssueComment). + Delete(reqToken(), repo.DeleteIssueComment) + m.Combo("/reactions"). + Get(repo.GetIssueCommentReactions). + Post(reqToken(), bind(api.EditReactionOption{}), repo.PostIssueCommentReaction). + Delete(reqToken(), bind(api.EditReactionOption{}), repo.DeleteIssueCommentReaction) + m.Group("/assets", func() { + m.Combo(""). + Get(repo.ListIssueCommentAttachments). + Post(reqToken(), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeAssetsAttachmentsIssues, context.QuotaTargetRepo), repo.CreateIssueCommentAttachment) + m.Combo("/{attachment_id}"). + Get(repo.GetIssueCommentAttachment). + Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueCommentAttachment). + Delete(reqToken(), mustNotBeArchived, repo.DeleteIssueCommentAttachment) + }, mustEnableAttachments) + }, commentAssignment(":id")) + }) + m.Group("/{index}", func() { + m.Combo("").Get(repo.GetIssue). + Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue). + Delete(reqToken(), reqAdmin(), context.ReferencesGitRepo(), repo.DeleteIssue) + m.Group("/comments", func() { + m.Combo("").Get(repo.ListIssueComments). + Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment) + m.Combo("/{id}", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated). + Delete(repo.DeleteIssueCommentDeprecated) + }) + m.Get("/timeline", repo.ListIssueCommentsAndTimeline) + m.Group("/labels", func() { + m.Combo("").Get(repo.ListIssueLabels). + Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels). + Put(reqToken(), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels). + Delete(reqToken(), bind(api.DeleteLabelsOption{}), repo.ClearIssueLabels) + m.Delete("/{id}", reqToken(), bind(api.DeleteLabelsOption{}), repo.DeleteIssueLabel) + }) + m.Group("/times", func() { + m.Combo(""). + Get(repo.ListTrackedTimes). + Post(bind(api.AddTimeOption{}), repo.AddTime). + Delete(repo.ResetIssueTime) + m.Delete("/{id}", repo.DeleteTime) + }, reqToken()) + m.Combo("/deadline").Post(reqToken(), bind(api.EditDeadlineOption{}), repo.UpdateIssueDeadline) + m.Group("/stopwatch", func() { + m.Post("/start", repo.StartIssueStopwatch) + m.Post("/stop", repo.StopIssueStopwatch) + m.Delete("/delete", repo.DeleteIssueStopwatch) + }, reqToken()) + m.Group("/subscriptions", func() { + m.Get("", repo.GetIssueSubscribers) + m.Get("/check", reqToken(), repo.CheckIssueSubscription) + m.Put("/{user}", reqToken(), repo.AddIssueSubscription) + m.Delete("/{user}", reqToken(), repo.DelIssueSubscription) + }) + m.Combo("/reactions"). + Get(repo.GetIssueReactions). + Post(reqToken(), bind(api.EditReactionOption{}), repo.PostIssueReaction). + Delete(reqToken(), bind(api.EditReactionOption{}), repo.DeleteIssueReaction) + m.Group("/assets", func() { + m.Combo(""). + Get(repo.ListIssueAttachments). + Post(reqToken(), mustNotBeArchived, context.EnforceQuotaAPI(quota_model.LimitSubjectSizeAssetsAttachmentsIssues, context.QuotaTargetRepo), repo.CreateIssueAttachment) + m.Combo("/{attachment_id}"). + Get(repo.GetIssueAttachment). + Patch(reqToken(), mustNotBeArchived, bind(api.EditAttachmentOptions{}), repo.EditIssueAttachment). + Delete(reqToken(), mustNotBeArchived, repo.DeleteIssueAttachment) + }, mustEnableAttachments) + m.Combo("/dependencies"). + Get(repo.GetIssueDependencies). + Post(reqToken(), mustNotBeArchived, bind(api.IssueMeta{}), repo.CreateIssueDependency). + Delete(reqToken(), mustNotBeArchived, bind(api.IssueMeta{}), repo.RemoveIssueDependency) + m.Combo("/blocks"). + Get(repo.GetIssueBlocks). + Post(reqToken(), bind(api.IssueMeta{}), repo.CreateIssueBlocking). + Delete(reqToken(), bind(api.IssueMeta{}), repo.RemoveIssueBlocking) + m.Group("/pin", func() { + m.Combo(""). + Post(reqToken(), reqAdmin(), repo.PinIssue). + Delete(reqToken(), reqAdmin(), repo.UnpinIssue) + m.Patch("/{position}", reqToken(), reqAdmin(), repo.MoveIssuePin) + }) + }) + }, mustEnableIssuesOrPulls) + m.Group("/labels", func() { + m.Combo("").Get(repo.ListLabels). + Post(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.CreateLabelOption{}), repo.CreateLabel) + m.Combo("/{id}").Get(repo.GetLabel). + Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditLabelOption{}), repo.EditLabel). + Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteLabel) + }) + m.Group("/milestones", func() { + m.Combo("").Get(repo.ListMilestones). + Post(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.CreateMilestoneOption{}), repo.CreateMilestone) + m.Combo("/{id}").Get(repo.GetMilestone). + Patch(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), bind(api.EditMilestoneOption{}), repo.EditMilestone). + Delete(reqToken(), reqRepoWriter(unit.TypeIssues, unit.TypePullRequests), repo.DeleteMilestone) + }) + }, repoAssignment(), checkTokenPublicOnly()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) + + // NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs + m.Group("/packages/{username}", func() { + m.Group("/{type}/{name}/{version}", func() { + m.Get("", reqToken(), packages.GetPackage) + m.Delete("", reqToken(), reqPackageAccess(perm.AccessModeWrite), packages.DeletePackage) + m.Get("/files", reqToken(), packages.ListPackageFiles) + }) + m.Get("/", reqToken(), packages.ListPackages) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryPackage), context.UserAssignmentAPI(), context.PackageAssignmentAPI(), reqPackageAccess(perm.AccessModeRead), checkTokenPublicOnly()) + + // Organizations + m.Get("/user/orgs", reqToken(), tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), org.ListMyOrgs) + m.Group("/users/{username}/orgs", func() { + m.Get("", reqToken(), org.ListUserOrgs) + m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser, auth_model.AccessTokenScopeCategoryOrganization), context.UserAssignmentAPI(), checkTokenPublicOnly()) + m.Post("/orgs", tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), reqToken(), bind(api.CreateOrgOption{}), org.Create) + m.Get("/orgs", org.GetAll, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization)) + m.Group("/orgs/{org}", func() { + m.Combo("").Get(org.Get). + Patch(reqToken(), reqOrgOwnership(), bind(api.EditOrgOption{}), org.Edit). + Delete(reqToken(), reqOrgOwnership(), org.Delete) + m.Combo("/repos").Get(user.ListOrgRepos). + Post(reqToken(), bind(api.CreateRepoOption{}), context.EnforceQuotaAPI(quota_model.LimitSubjectSizeReposAll, context.QuotaTargetOrg), repo.CreateOrgRepo) + m.Group("/members", func() { + m.Get("", reqToken(), org.ListMembers) + m.Combo("/{username}").Get(reqToken(), org.IsMember). + Delete(reqToken(), reqOrgOwnership(), org.DeleteMember) + }) + addActionsRoutes( + m, + reqOrgOwnership(), + org.NewAction(), + ) + m.Group("/public_members", func() { + m.Get("", org.ListPublicMembers) + m.Combo("/{username}").Get(org.IsPublicMember). + Put(reqToken(), reqOrgMembership(), org.PublicizeMember). + Delete(reqToken(), reqOrgMembership(), org.ConcealMember) + }) + m.Group("/teams", func() { + m.Get("", org.ListTeams) + m.Post("", reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam) + m.Get("/search", org.SearchTeam) + }, reqToken(), reqOrgMembership()) + m.Group("/labels", func() { + m.Get("", org.ListLabels) + m.Post("", reqToken(), reqOrgOwnership(), bind(api.CreateLabelOption{}), org.CreateLabel) + m.Combo("/{id}").Get(reqToken(), org.GetLabel). + Patch(reqToken(), reqOrgOwnership(), bind(api.EditLabelOption{}), org.EditLabel). + Delete(reqToken(), reqOrgOwnership(), org.DeleteLabel) + }) + m.Group("/hooks", func() { + m.Combo("").Get(org.ListHooks). + Post(bind(api.CreateHookOption{}), org.CreateHook) + m.Combo("/{id}").Get(org.GetHook). + Patch(bind(api.EditHookOption{}), org.EditHook). + Delete(org.DeleteHook) + }, reqToken(), reqOrgOwnership(), reqWebhooksEnabled()) + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateUserAvatarOption{}), org.UpdateAvatar) + m.Delete("", org.DeleteAvatar) + }, reqToken(), reqOrgOwnership()) + m.Get("/activities/feeds", org.ListOrgActivityFeeds) + + if setting.Quota.Enabled { + m.Group("/quota", func() { + m.Get("", org.GetQuota) + m.Get("/check", org.CheckQuota) + m.Get("/attachments", org.ListQuotaAttachments) + m.Get("/packages", org.ListQuotaPackages) + m.Get("/artifacts", org.ListQuotaArtifacts) + }, reqToken(), reqOrgOwnership()) + } + + m.Group("", func() { + m.Get("/list_blocked", org.ListBlockedUsers) + m.Group("", func() { + m.Put("/block/{username}", org.BlockUser) + m.Put("/unblock/{username}", org.UnblockUser) + }, context.UserAssignmentAPI()) + }, reqToken(), reqOrgOwnership()) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true), checkTokenPublicOnly()) + m.Group("/teams/{teamid}", func() { + m.Combo("").Get(reqToken(), org.GetTeam). + Patch(reqToken(), reqOrgOwnership(), bind(api.EditTeamOption{}), org.EditTeam). + Delete(reqToken(), reqOrgOwnership(), org.DeleteTeam) + m.Group("/members", func() { + m.Get("", reqToken(), org.GetTeamMembers) + m.Combo("/{username}"). + Get(reqToken(), org.GetTeamMember). + Put(reqToken(), reqOrgOwnership(), org.AddTeamMember). + Delete(reqToken(), reqOrgOwnership(), org.RemoveTeamMember) + }) + m.Group("/repos", func() { + m.Get("", reqToken(), org.GetTeamRepos) + m.Combo("/{org}/{reponame}"). + Put(reqToken(), org.AddTeamRepository). + Delete(reqToken(), org.RemoveTeamRepository). + Get(reqToken(), org.GetTeamRepo) + }) + m.Get("/activities/feeds", org.ListTeamActivityFeeds) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(false, true), reqToken(), reqTeamMembership(), checkTokenPublicOnly()) + + m.Group("/admin", func() { + m.Group("/cron", func() { + m.Get("", admin.ListCronTasks) + m.Post("/{task}", admin.PostCronTask) + }) + m.Get("/orgs", admin.GetAllOrgs) + m.Group("/users", func() { + m.Get("", admin.SearchUsers) + m.Post("", bind(api.CreateUserOption{}), admin.CreateUser) + m.Group("/{username}", func() { + m.Combo("").Patch(bind(api.EditUserOption{}), admin.EditUser). + Delete(admin.DeleteUser) + m.Group("/keys", func() { + m.Post("", bind(api.CreateKeyOption{}), admin.CreatePublicKey) + m.Delete("/{id}", admin.DeleteUserPublicKey) + }) + m.Get("/orgs", org.ListUserOrgs) + m.Post("/orgs", bind(api.CreateOrgOption{}), admin.CreateOrg) + m.Post("/repos", bind(api.CreateRepoOption{}), admin.CreateRepo) + m.Post("/rename", bind(api.RenameUserOption{}), admin.RenameUser) + if setting.Quota.Enabled { + m.Group("/quota", func() { + m.Get("", admin.GetUserQuota) + m.Post("/groups", bind(api.SetUserQuotaGroupsOptions{}), admin.SetUserQuotaGroups) + }) + } + }, context.UserAssignmentAPI()) + }) + m.Group("/emails", func() { + m.Get("", admin.GetAllEmails) + m.Get("/search", admin.SearchEmail) + }) + m.Group("/unadopted", func() { + m.Get("", admin.ListUnadoptedRepositories) + m.Post("/{username}/{reponame}", admin.AdoptRepository) + m.Delete("/{username}/{reponame}", admin.DeleteUnadoptedRepository) + }) + m.Group("/hooks", func() { + m.Combo("").Get(admin.ListHooks). + Post(bind(api.CreateHookOption{}), admin.CreateHook) + m.Combo("/{id}").Get(admin.GetHook). + Patch(bind(api.EditHookOption{}), admin.EditHook). + Delete(admin.DeleteHook) + }) + m.Group("/runners", func() { + m.Get("/registration-token", admin.GetRegistrationToken) + }) + if setting.Quota.Enabled { + m.Group("/quota", func() { + m.Group("/rules", func() { + m.Combo("").Get(admin.ListQuotaRules). + Post(bind(api.CreateQuotaRuleOptions{}), admin.CreateQuotaRule) + m.Combo("/{quotarule}", context.QuotaRuleAssignmentAPI()). + Get(admin.GetQuotaRule). + Patch(bind(api.EditQuotaRuleOptions{}), admin.EditQuotaRule). + Delete(admin.DeleteQuotaRule) + }) + m.Group("/groups", func() { + m.Combo("").Get(admin.ListQuotaGroups). + Post(bind(api.CreateQuotaGroupOptions{}), admin.CreateQuotaGroup) + m.Group("/{quotagroup}", func() { + m.Combo("").Get(admin.GetQuotaGroup). + Delete(admin.DeleteQuotaGroup) + m.Group("/rules", func() { + m.Combo("/{quotarule}", context.QuotaRuleAssignmentAPI()). + Put(admin.AddRuleToQuotaGroup). + Delete(admin.RemoveRuleFromQuotaGroup) + }) + m.Group("/users", func() { + m.Get("", admin.ListUsersInQuotaGroup) + m.Combo("/{username}", context.UserAssignmentAPI()). + Put(admin.AddUserToQuotaGroup). + Delete(admin.RemoveUserFromQuotaGroup) + }) + }, context.QuotaGroupAssignmentAPI()) + }) + }) + } + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) + + m.Group("/topics", func() { + m.Get("/search", repo.TopicSearch) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) + }, sudo()) + + return m +} diff --git a/routers/api/v1/misc/gitignore.go b/routers/api/v1/misc/gitignore.go new file mode 100644 index 0000000..dffd771 --- /dev/null +++ b/routers/api/v1/misc/gitignore.go @@ -0,0 +1,56 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/options" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// Shows a list of all Gitignore templates +func ListGitignoresTemplates(ctx *context.APIContext) { + // swagger:operation GET /gitignore/templates miscellaneous listGitignoresTemplates + // --- + // summary: Returns a list of all gitignore templates + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/GitignoreTemplateList" + ctx.JSON(http.StatusOK, repo_module.Gitignores) +} + +// SHows information about a gitignore template +func GetGitignoreTemplateInfo(ctx *context.APIContext) { + // swagger:operation GET /gitignore/templates/{name} miscellaneous getGitignoreTemplateInfo + // --- + // summary: Returns information about a gitignore template + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: name of the template + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/GitignoreTemplateInfo" + // "404": + // "$ref": "#/responses/notFound" + name := util.PathJoinRelX(ctx.Params("name")) + + text, err := options.Gitignore(name) + if err != nil { + ctx.NotFound() + return + } + + ctx.JSON(http.StatusOK, &structs.GitignoreTemplateInfo{Name: name, Source: string(text)}) +} diff --git a/routers/api/v1/misc/label_templates.go b/routers/api/v1/misc/label_templates.go new file mode 100644 index 0000000..cc11f37 --- /dev/null +++ b/routers/api/v1/misc/label_templates.go @@ -0,0 +1,60 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// Shows a list of all Label templates +func ListLabelTemplates(ctx *context.APIContext) { + // swagger:operation GET /label/templates miscellaneous listLabelTemplates + // --- + // summary: Returns a list of all label templates + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/LabelTemplateList" + result := make([]string, len(repo_module.LabelTemplateFiles)) + for i := range repo_module.LabelTemplateFiles { + result[i] = repo_module.LabelTemplateFiles[i].DisplayName + } + + ctx.JSON(http.StatusOK, result) +} + +// Shows all labels in a template +func GetLabelTemplate(ctx *context.APIContext) { + // swagger:operation GET /label/templates/{name} miscellaneous getLabelTemplateInfo + // --- + // summary: Returns all labels in a template + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: name of the template + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/LabelTemplateInfo" + // "404": + // "$ref": "#/responses/notFound" + name := util.PathJoinRelX(ctx.Params("name")) + + labels, err := repo_module.LoadTemplateLabelsByDisplayName(name) + if err != nil { + ctx.NotFound() + return + } + + ctx.JSON(http.StatusOK, convert.ToLabelTemplateList(labels)) +} diff --git a/routers/api/v1/misc/licenses.go b/routers/api/v1/misc/licenses.go new file mode 100644 index 0000000..2a980f5 --- /dev/null +++ b/routers/api/v1/misc/licenses.go @@ -0,0 +1,76 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "fmt" + "net/http" + "net/url" + + "code.gitea.io/gitea/modules/options" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// Returns a list of all License templates +func ListLicenseTemplates(ctx *context.APIContext) { + // swagger:operation GET /licenses miscellaneous listLicenseTemplates + // --- + // summary: Returns a list of all license templates + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/LicenseTemplateList" + response := make([]api.LicensesTemplateListEntry, len(repo_module.Licenses)) + for i, license := range repo_module.Licenses { + response[i] = api.LicensesTemplateListEntry{ + Key: license, + Name: license, + URL: fmt.Sprintf("%sapi/v1/licenses/%s", setting.AppURL, url.PathEscape(license)), + } + } + ctx.JSON(http.StatusOK, response) +} + +// Returns information about a gitignore template +func GetLicenseTemplateInfo(ctx *context.APIContext) { + // swagger:operation GET /licenses/{name} miscellaneous getLicenseTemplateInfo + // --- + // summary: Returns information about a license template + // produces: + // - application/json + // parameters: + // - name: name + // in: path + // description: name of the license + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/LicenseTemplateInfo" + // "404": + // "$ref": "#/responses/notFound" + name := util.PathJoinRelX(ctx.Params("name")) + + text, err := options.License(name) + if err != nil { + ctx.NotFound() + return + } + + response := api.LicenseTemplateInfo{ + Key: name, + Name: name, + URL: fmt.Sprintf("%sapi/v1/licenses/%s", setting.AppURL, url.PathEscape(name)), + Body: string(text), + // This is for combatibilty with the GitHub API. This Text is for some reason added to each License response. + Implementation: "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file", + } + + ctx.JSON(http.StatusOK, response) +} diff --git a/routers/api/v1/misc/markup.go b/routers/api/v1/misc/markup.go new file mode 100644 index 0000000..9699c79 --- /dev/null +++ b/routers/api/v1/misc/markup.go @@ -0,0 +1,110 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" +) + +// Markup render markup document to HTML +func Markup(ctx *context.APIContext) { + // swagger:operation POST /markup miscellaneous renderMarkup + // --- + // summary: Render a markup document as HTML + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MarkupOption" + // consumes: + // - application/json + // produces: + // - text/html + // responses: + // "200": + // "$ref": "#/responses/MarkupRender" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.MarkupOption) + + if ctx.HasAPIError() { + ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg()) + return + } + + common.RenderMarkup(ctx.Base, ctx.Repo, form.Mode, form.Text, form.Context, form.FilePath, form.Wiki) +} + +// Markdown render markdown document to HTML +func Markdown(ctx *context.APIContext) { + // swagger:operation POST /markdown miscellaneous renderMarkdown + // --- + // summary: Render a markdown document as HTML + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MarkdownOption" + // consumes: + // - application/json + // produces: + // - text/html + // responses: + // "200": + // "$ref": "#/responses/MarkdownRender" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.MarkdownOption) + + if ctx.HasAPIError() { + ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg()) + return + } + + mode := "markdown" + if form.Mode == "comment" || form.Mode == "gfm" { + mode = form.Mode + } + + common.RenderMarkup(ctx.Base, ctx.Repo, mode, form.Text, form.Context, "", form.Wiki) +} + +// MarkdownRaw render raw markdown HTML +func MarkdownRaw(ctx *context.APIContext) { + // swagger:operation POST /markdown/raw miscellaneous renderMarkdownRaw + // --- + // summary: Render raw markdown as HTML + // parameters: + // - name: body + // in: body + // description: Request body to render + // required: true + // schema: + // type: string + // consumes: + // - text/plain + // produces: + // - text/html + // responses: + // "200": + // "$ref": "#/responses/MarkdownRender" + // "422": + // "$ref": "#/responses/validationError" + defer ctx.Req.Body.Close() + if err := markdown.RenderRaw(&markup.RenderContext{ + Ctx: ctx, + }, ctx.Req.Body, ctx.Resp); err != nil { + ctx.InternalServerError(err) + return + } +} diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go new file mode 100644 index 0000000..5236fd0 --- /dev/null +++ b/routers/api/v1/misc/markup_test.go @@ -0,0 +1,184 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + go_context "context" + "io" + "net/http" + "strings" + "testing" + + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +const ( + AppURL = "http://localhost:3000/" + Repo = "gogits/gogs" + FullURL = AppURL + Repo + "/" +) + +func testRenderMarkup(t *testing.T, mode, filePath, text, responseBody string, responseCode int) { + setting.AppURL = AppURL + options := api.MarkupOption{ + Mode: mode, + Text: text, + Context: Repo, + Wiki: true, + FilePath: filePath, + } + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup") + web.SetForm(ctx, &options) + Markup(ctx) + assert.Equal(t, responseBody, resp.Body.String()) + assert.Equal(t, responseCode, resp.Code) + resp.Body.Reset() +} + +func testRenderMarkdown(t *testing.T, mode, text, responseBody string, responseCode int) { + setting.AppURL = AppURL + options := api.MarkdownOption{ + Mode: mode, + Text: text, + Context: Repo, + Wiki: true, + } + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") + web.SetForm(ctx, &options) + Markdown(ctx) + assert.Equal(t, responseBody, resp.Body.String()) + assert.Equal(t, responseCode, resp.Code) + resp.Body.Reset() +} + +func TestAPI_RenderGFM(t *testing.T) { + markup.Init(&markup.ProcessorHelper{ + IsUsernameMentionable: func(ctx go_context.Context, username string) bool { + return username == "r-lyeh" + }, + }) + + testCasesCommon := []string{ + // dear imgui wiki markdown extract: special wiki syntax + `Wiki! Enjoy :) +- [[Links, Language bindings, Engine bindings|Links]] +- [[Tips]] +- Bezier widget (by @r-lyeh) https://github.com/ocornut/imgui/issues/786`, + // rendered + `

Wiki! Enjoy :)

+ +`, + // Guard wiki sidebar: special syntax + `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`, + // rendered + `

Guardfile-DSL / Configuring-Guard

+`, + // special syntax + `[[Name|Link]]`, + // rendered + `

Name

+`, + // empty + ``, + // rendered + ``, + } + + testCasesDocument := []string{ + // wine-staging wiki home extract: special wiki syntax, images + `## What is Wine Staging? +**Wine Staging** on website [wine-staging.com](http://wine-staging.com). + +## Quick Links +Here are some links to the most important topics. You can find the full list of pages at the sidebar. + +[[Configuration]] +[[images/icon-bug.png]] +`, + // rendered + `

What is Wine Staging?

+

Wine Staging on website wine-staging.com.

+ +

Here are some links to the most important topics. You can find the full list of pages at the sidebar.

+

Configuration +images/icon-bug.png

+`, + } + + for i := 0; i < len(testCasesCommon); i += 2 { + text := testCasesCommon[i] + response := testCasesCommon[i+1] + testRenderMarkdown(t, "gfm", text, response, http.StatusOK) + testRenderMarkup(t, "gfm", "", text, response, http.StatusOK) + testRenderMarkdown(t, "comment", text, response, http.StatusOK) + testRenderMarkup(t, "comment", "", text, response, http.StatusOK) + testRenderMarkup(t, "file", "path/test.md", text, response, http.StatusOK) + } + + for i := 0; i < len(testCasesDocument); i += 2 { + text := testCasesDocument[i] + response := testCasesDocument[i+1] + testRenderMarkdown(t, "gfm", text, response, http.StatusOK) + testRenderMarkup(t, "gfm", "", text, response, http.StatusOK) + testRenderMarkup(t, "file", "path/test.md", text, response, http.StatusOK) + } + + testRenderMarkup(t, "file", "path/test.unknown", "## Test", "Unsupported render extension: .unknown\n", http.StatusUnprocessableEntity) + testRenderMarkup(t, "unknown", "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity) +} + +var simpleCases = []string{ + // Guard wiki sidebar: special syntax + `[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]`, + // rendered + `

[[Guardfile-DSL / Configuring-Guard|Guardfile-DSL---Configuring-Guard]]

+`, + // special syntax + `[[Name|Link]]`, + // rendered + `

[[Name|Link]]

+`, + // empty + ``, + // rendered + ``, +} + +func TestAPI_RenderSimple(t *testing.T) { + setting.AppURL = AppURL + options := api.MarkdownOption{ + Mode: "markdown", + Text: "", + Context: Repo, + } + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") + for i := 0; i < len(simpleCases); i += 2 { + options.Text = simpleCases[i] + web.SetForm(ctx, &options) + Markdown(ctx) + assert.Equal(t, simpleCases[i+1], resp.Body.String()) + resp.Body.Reset() + } +} + +func TestAPI_RenderRaw(t *testing.T) { + setting.AppURL = AppURL + ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown") + for i := 0; i < len(simpleCases); i += 2 { + ctx.Req.Body = io.NopCloser(strings.NewReader(simpleCases[i])) + MarkdownRaw(ctx) + assert.Equal(t, simpleCases[i+1], resp.Body.String()) + resp.Body.Reset() + } +} diff --git a/routers/api/v1/misc/nodeinfo.go b/routers/api/v1/misc/nodeinfo.go new file mode 100644 index 0000000..9c2a0db --- /dev/null +++ b/routers/api/v1/misc/nodeinfo.go @@ -0,0 +1,80 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +const cacheKeyNodeInfoUsage = "API_NodeInfoUsage" + +// NodeInfo returns the NodeInfo for the Gitea instance to allow for federation +func NodeInfo(ctx *context.APIContext) { + // swagger:operation GET /nodeinfo miscellaneous getNodeInfo + // --- + // summary: Returns the nodeinfo of the Gitea application + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/NodeInfo" + + nodeInfoUsage := structs.NodeInfoUsage{} + if setting.Federation.ShareUserStatistics { + var cached bool + nodeInfoUsage, cached = ctx.Cache.Get(cacheKeyNodeInfoUsage).(structs.NodeInfoUsage) + + if !cached { + usersTotal := int(user_model.CountUsers(ctx, nil)) + now := time.Now() + timeOneMonthAgo := now.AddDate(0, -1, 0).Unix() + timeHaveYearAgo := now.AddDate(0, -6, 0).Unix() + usersActiveMonth := int(user_model.CountUsers(ctx, &user_model.CountUserFilter{LastLoginSince: &timeOneMonthAgo})) + usersActiveHalfyear := int(user_model.CountUsers(ctx, &user_model.CountUserFilter{LastLoginSince: &timeHaveYearAgo})) + + allIssues, _ := issues_model.CountIssues(ctx, &issues_model.IssuesOptions{}) + allComments, _ := issues_model.CountComments(ctx, &issues_model.FindCommentsOptions{}) + + nodeInfoUsage = structs.NodeInfoUsage{ + Users: structs.NodeInfoUsageUsers{ + Total: usersTotal, + ActiveMonth: usersActiveMonth, + ActiveHalfyear: usersActiveHalfyear, + }, + LocalPosts: int(allIssues), + LocalComments: int(allComments), + } + + if err := ctx.Cache.Put(cacheKeyNodeInfoUsage, nodeInfoUsage, 180); err != nil { + ctx.InternalServerError(err) + return + } + } + } + + nodeInfo := &structs.NodeInfo{ + Version: "2.1", + Software: structs.NodeInfoSoftware{ + Name: "forgejo", + Version: setting.AppVer, + Repository: "https://codeberg.org/forgejo/forgejo.git", + Homepage: "https://forgejo.org/", + }, + Protocols: []string{"activitypub"}, + Services: structs.NodeInfoServices{ + Inbound: []string{}, + Outbound: []string{"rss2.0"}, + }, + OpenRegistrations: setting.Service.ShowRegistrationButton, + Usage: nodeInfoUsage, + } + ctx.JSON(http.StatusOK, nodeInfo) +} diff --git a/routers/api/v1/misc/signing.go b/routers/api/v1/misc/signing.go new file mode 100644 index 0000000..24a46c1 --- /dev/null +++ b/routers/api/v1/misc/signing.go @@ -0,0 +1,63 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "fmt" + "net/http" + + 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) { + // swagger:operation GET /signing-key.gpg miscellaneous getSigningKey + // --- + // summary: Get default signing-key.gpg + // produces: + // - text/plain + // responses: + // "200": + // description: "GPG armored public key" + // schema: + // type: string + + // swagger:operation GET /repos/{owner}/{repo}/signing-key.gpg repository repoSigningKey + // --- + // summary: Get signing-key.gpg 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: "GPG armored public key" + // schema: + // type: string + + path := "" + if ctx.Repo != nil && ctx.Repo.Repository != nil { + path = ctx.Repo.Repository.RepoPath() + } + + content, err := asymkey_service.PublicSigningKey(ctx, path) + if err != nil { + ctx.Error(http.StatusInternalServerError, "gpg export", err) + return + } + _, err = ctx.Write([]byte(content)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "gpg export", fmt.Errorf("Error writing key content %w", err)) + } +} diff --git a/routers/api/v1/misc/version.go b/routers/api/v1/misc/version.go new file mode 100644 index 0000000..e3b43a0 --- /dev/null +++ b/routers/api/v1/misc/version.go @@ -0,0 +1,25 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package misc + +import ( + "net/http" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +// Version shows the version of the Gitea server +func Version(ctx *context.APIContext) { + // swagger:operation GET /version miscellaneous getVersion + // --- + // summary: Returns the version of the Gitea application + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/ServerVersion" + ctx.JSON(http.StatusOK, &structs.ServerVersion{Version: setting.AppVer}) +} diff --git a/routers/api/v1/notify/notifications.go b/routers/api/v1/notify/notifications.go new file mode 100644 index 0000000..46b3c7f --- /dev/null +++ b/routers/api/v1/notify/notifications.go @@ -0,0 +1,77 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notify + +import ( + "net/http" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" +) + +// NewAvailable check if unread notifications exist +func NewAvailable(ctx *context.APIContext) { + // swagger:operation GET /notifications/new notification notifyNewAvailable + // --- + // summary: Check if unread notifications exist + // responses: + // "200": + // "$ref": "#/responses/NotificationCount" + + total, err := db.Count[activities_model.Notification](ctx, activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + Status: []activities_model.NotificationStatus{activities_model.NotificationStatusUnread}, + }) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "db.Count[activities_model.Notification]", err) + return + } + + ctx.JSON(http.StatusOK, api.NotificationCount{New: total}) +} + +func getFindNotificationOptions(ctx *context.APIContext) *activities_model.FindNotificationOptions { + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return nil + } + opts := &activities_model.FindNotificationOptions{ + ListOptions: utils.GetListOptions(ctx), + UserID: ctx.Doer.ID, + UpdatedBeforeUnix: before, + UpdatedAfterUnix: since, + } + if !ctx.FormBool("all") { + statuses := ctx.FormStrings("status-types") + opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread", "pinned"}) + } + + subjectTypes := ctx.FormStrings("subject-type") + if len(subjectTypes) != 0 { + opts.Source = subjectToSource(subjectTypes) + } + + return opts +} + +func subjectToSource(value []string) (result []activities_model.NotificationSource) { + for _, v := range value { + switch strings.ToLower(v) { + case "issue": + result = append(result, activities_model.NotificationSourceIssue) + case "pull": + result = append(result, activities_model.NotificationSourcePullRequest) + case "commit": + result = append(result, activities_model.NotificationSourceCommit) + case "repository": + result = append(result, activities_model.NotificationSourceRepository) + } + } + return result +} diff --git a/routers/api/v1/notify/repo.go b/routers/api/v1/notify/repo.go new file mode 100644 index 0000000..1744426 --- /dev/null +++ b/routers/api/v1/notify/repo.go @@ -0,0 +1,227 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notify + +import ( + "net/http" + "strings" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func statusStringToNotificationStatus(status string) activities_model.NotificationStatus { + switch strings.ToLower(strings.TrimSpace(status)) { + case "unread": + return activities_model.NotificationStatusUnread + case "read": + return activities_model.NotificationStatusRead + case "pinned": + return activities_model.NotificationStatusPinned + default: + return 0 + } +} + +func statusStringsToNotificationStatuses(statuses, defaultStatuses []string) []activities_model.NotificationStatus { + if len(statuses) == 0 { + statuses = defaultStatuses + } + results := make([]activities_model.NotificationStatus, 0, len(statuses)) + for _, status := range statuses { + notificationStatus := statusStringToNotificationStatus(status) + if notificationStatus > 0 { + results = append(results, notificationStatus) + } + } + return results +} + +// ListRepoNotifications list users's notification threads on a specific repo +func ListRepoNotifications(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/notifications notification notifyGetRepoList + // --- + // summary: List users's notification threads on a specific repo + // consumes: + // - application/json + // 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: all + // in: query + // description: If true, show notifications marked as read. Default value is false + // type: boolean + // - name: status-types + // in: query + // description: "Show notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread & pinned" + // type: array + // collectionFormat: multi + // items: + // type: string + // - name: subject-type + // in: query + // description: "filter notifications by subject type" + // type: array + // collectionFormat: multi + // items: + // type: string + // enum: [issue,pull,commit,repository] + // - name: since + // in: query + // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - name: before + // in: query + // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - 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/NotificationThreadList" + opts := getFindNotificationOptions(ctx) + if ctx.Written() { + return + } + opts.RepoID = ctx.Repo.Repository.ID + + totalCount, err := db.Count[activities_model.Notification](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + nl, err := db.Find[activities_model.Notification](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + err = activities_model.NotificationList(nl).LoadAttributes(ctx) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetTotalCountHeader(totalCount) + + ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl)) +} + +// ReadRepoNotifications mark notification threads as read on a specific repo +func ReadRepoNotifications(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/notifications notification notifyReadRepoList + // --- + // summary: Mark notification threads as read, pinned or unread on a specific repo + // consumes: + // - application/json + // 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: all + // in: query + // description: If true, mark all notifications on this repo. Default value is false + // type: string + // required: false + // - name: status-types + // in: query + // description: "Mark notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread." + // type: array + // collectionFormat: multi + // items: + // type: string + // required: false + // - name: to-status + // in: query + // description: Status to mark notifications as. Defaults to read. + // type: string + // required: false + // - name: last_read_at + // in: query + // description: Describes the last point that notifications were checked. Anything updated since this time will not be updated. + // type: string + // format: date-time + // required: false + // responses: + // "205": + // "$ref": "#/responses/NotificationThreadList" + + lastRead := int64(0) + qLastRead := ctx.FormTrim("last_read_at") + if len(qLastRead) > 0 { + tmpLastRead, err := time.Parse(time.RFC3339, qLastRead) + if err != nil { + ctx.Error(http.StatusBadRequest, "Parse", err) + return + } + if !tmpLastRead.IsZero() { + lastRead = tmpLastRead.Unix() + } + } + + opts := &activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + UpdatedBeforeUnix: lastRead, + } + + if !ctx.FormBool("all") { + statuses := ctx.FormStrings("status-types") + opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread"}) + } + nl, err := db.Find[activities_model.Notification](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + targetStatus := statusStringToNotificationStatus(ctx.FormString("to-status")) + if targetStatus == 0 { + targetStatus = activities_model.NotificationStatusRead + } + + changed := make([]*structs.NotificationThread, 0, len(nl)) + + for _, n := range nl { + notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus) + if err != nil { + ctx.InternalServerError(err) + return + } + _ = notif.LoadAttributes(ctx) + changed = append(changed, convert.ToNotificationThread(ctx, notif)) + } + ctx.JSON(http.StatusResetContent, changed) +} diff --git a/routers/api/v1/notify/threads.go b/routers/api/v1/notify/threads.go new file mode 100644 index 0000000..8e12d35 --- /dev/null +++ b/routers/api/v1/notify/threads.go @@ -0,0 +1,118 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notify + +import ( + "fmt" + "net/http" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetThread get notification by ID +func GetThread(ctx *context.APIContext) { + // swagger:operation GET /notifications/threads/{id} notification notifyGetThread + // --- + // summary: Get notification thread by ID + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of notification thread + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/NotificationThread" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + n := getThread(ctx) + if n == nil { + return + } + if err := n.LoadAttributes(ctx); err != nil && !issues_model.IsErrCommentNotExist(err) { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToNotificationThread(ctx, n)) +} + +// ReadThread mark notification as read by ID +func ReadThread(ctx *context.APIContext) { + // swagger:operation PATCH /notifications/threads/{id} notification notifyReadThread + // --- + // summary: Mark notification thread as read by ID + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of notification thread + // type: string + // required: true + // - name: to-status + // in: query + // description: Status to mark notifications as + // type: string + // default: read + // required: false + // responses: + // "205": + // "$ref": "#/responses/NotificationThread" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + n := getThread(ctx) + if n == nil { + return + } + + targetStatus := statusStringToNotificationStatus(ctx.FormString("to-status")) + if targetStatus == 0 { + targetStatus = activities_model.NotificationStatusRead + } + + notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus) + if err != nil { + ctx.InternalServerError(err) + return + } + if err = notif.LoadAttributes(ctx); err != nil && !issues_model.IsErrCommentNotExist(err) { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusResetContent, convert.ToNotificationThread(ctx, notif)) +} + +func getThread(ctx *context.APIContext) *activities_model.Notification { + n, err := activities_model.GetNotificationByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if db.IsErrNotExist(err) { + ctx.Error(http.StatusNotFound, "GetNotificationByID", err) + } else { + ctx.InternalServerError(err) + } + return nil + } + if n.UserID != ctx.Doer.ID && !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden, "GetNotificationByID", fmt.Errorf("only user itself and admin are allowed to read/change this thread %d", n.ID)) + return nil + } + return n +} diff --git a/routers/api/v1/notify/user.go b/routers/api/v1/notify/user.go new file mode 100644 index 0000000..879f484 --- /dev/null +++ b/routers/api/v1/notify/user.go @@ -0,0 +1,175 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package notify + +import ( + "net/http" + "time" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListNotifications list users's notification threads +func ListNotifications(ctx *context.APIContext) { + // swagger:operation GET /notifications notification notifyGetList + // --- + // summary: List users's notification threads + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: all + // in: query + // description: If true, show notifications marked as read. Default value is false + // type: boolean + // - name: status-types + // in: query + // description: "Show notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread & pinned." + // type: array + // collectionFormat: multi + // items: + // type: string + // - name: subject-type + // in: query + // description: "filter notifications by subject type" + // type: array + // collectionFormat: multi + // items: + // type: string + // enum: [issue,pull,commit,repository] + // - name: since + // in: query + // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - name: before + // in: query + // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - 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/NotificationThreadList" + opts := getFindNotificationOptions(ctx) + if ctx.Written() { + return + } + + totalCount, err := db.Count[activities_model.Notification](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + nl, err := db.Find[activities_model.Notification](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + err = activities_model.NotificationList(nl).LoadAttributes(ctx) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetTotalCountHeader(totalCount) + ctx.JSON(http.StatusOK, convert.ToNotifications(ctx, nl)) +} + +// ReadNotifications mark notification threads as read, unread, or pinned +func ReadNotifications(ctx *context.APIContext) { + // swagger:operation PUT /notifications notification notifyReadList + // --- + // summary: Mark notification threads as read, pinned or unread + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: last_read_at + // in: query + // description: Describes the last point that notifications were checked. Anything updated since this time will not be updated. + // type: string + // format: date-time + // required: false + // - name: all + // in: query + // description: If true, mark all notifications on this repo. Default value is false + // type: string + // required: false + // - name: status-types + // in: query + // description: "Mark notifications with the provided status types. Options are: unread, read and/or pinned. Defaults to unread." + // type: array + // collectionFormat: multi + // items: + // type: string + // required: false + // - name: to-status + // in: query + // description: Status to mark notifications as, Defaults to read. + // type: string + // required: false + // responses: + // "205": + // "$ref": "#/responses/NotificationThreadList" + + lastRead := int64(0) + qLastRead := ctx.FormTrim("last_read_at") + if len(qLastRead) > 0 { + tmpLastRead, err := time.Parse(time.RFC3339, qLastRead) + if err != nil { + ctx.Error(http.StatusBadRequest, "Parse", err) + return + } + if !tmpLastRead.IsZero() { + lastRead = tmpLastRead.Unix() + } + } + opts := &activities_model.FindNotificationOptions{ + UserID: ctx.Doer.ID, + UpdatedBeforeUnix: lastRead, + } + if !ctx.FormBool("all") { + statuses := ctx.FormStrings("status-types") + opts.Status = statusStringsToNotificationStatuses(statuses, []string{"unread"}) + } + nl, err := db.Find[activities_model.Notification](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + targetStatus := statusStringToNotificationStatus(ctx.FormString("to-status")) + if targetStatus == 0 { + targetStatus = activities_model.NotificationStatusRead + } + + changed := make([]*structs.NotificationThread, 0, len(nl)) + + for _, n := range nl { + notif, err := activities_model.SetNotificationStatus(ctx, n.ID, ctx.Doer, targetStatus) + if err != nil { + ctx.InternalServerError(err) + return + } + _ = notif.LoadAttributes(ctx) + changed = append(changed, convert.ToNotificationThread(ctx, notif)) + } + + ctx.JSON(http.StatusResetContent, changed) +} diff --git a/routers/api/v1/org/action.go b/routers/api/v1/org/action.go new file mode 100644 index 0000000..03a1fa8 --- /dev/null +++ b/routers/api/v1/org/action.go @@ -0,0 +1,473 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + secret_model "code.gitea.io/gitea/models/secret" + 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" + secret_service "code.gitea.io/gitea/services/secrets" +) + +// ListActionsSecrets list an organization's actions secrets +func (Action) ListActionsSecrets(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/secrets organization orgListActionsSecrets + // --- + // summary: List an organization's actions secrets + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/SecretList" + // "404": + // "$ref": "#/responses/notFound" + + opts := &secret_model.FindSecretsOptions{ + OwnerID: ctx.Org.Organization.ID, + ListOptions: utils.GetListOptions(ctx), + } + + secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiSecrets := make([]*api.Secret, len(secrets)) + for k, v := range secrets { + apiSecrets[k] = &api.Secret{ + Name: v.Name, + Created: v.CreatedUnix.AsTime(), + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiSecrets) +} + +// create or update one secret of the organization +func (Action) CreateOrUpdateSecret(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/actions/secrets/{secretname} organization updateOrgSecret + // --- + // summary: Create or Update a secret value in an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of organization + // type: string + // required: true + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrUpdateSecretOption" + // responses: + // "201": + // description: response when creating a secret + // "204": + // description: response when updating a secret + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) + + _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname"), opt.Data) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err) + } + return + } + + if created { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } +} + +// DeleteSecret delete one secret of the organization +func (Action) DeleteSecret(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/actions/secrets/{secretname} organization deleteOrgSecret + // --- + // summary: Delete a secret in an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of organization + // type: string + // required: true + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // responses: + // "204": + // description: delete one secret of the organization + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + err := secret_service.DeleteSecretByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("secretname")) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteSecret", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization +// GetRegistrationToken returns the token to register org runners +func (Action) GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/runners/registration-token organization orgGetRunnerRegistrationToken + // --- + // summary: Get an organization's actions runner registration token + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, ctx.Org.Organization.ID, 0) +} + +// ListVariables list org-level variables +func (Action) ListVariables(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/variables organization getOrgVariablesList + // --- + // summary: Get an org-level variables list + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} + +// GetVariable get an org-level variable +func (Action) GetVariable(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/actions/variables/{variablename} organization getOrgVariable + // --- + // summary: Get an org-level variable + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// DeleteVariable delete an org-level variable +func (Action) DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/actions/variables/{variablename} organization deleteOrgVariable + // --- + // summary: Delete an org-level variable + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, ctx.Org.Organization.ID, 0, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateVariable create an org-level variable +func (Action) CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/actions/variables/{variablename} organization createOrgVariable + // --- + // summary: Create an org-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating an org-level variable + // "204": + // description: response when creating an org-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + ownerID := ctx.Org.Organization.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update an org-level variable +func (Action) UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/actions/variables/{variablename} organization updateOrgVariable + // --- + // summary: Update an org-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating an org-level variable + // "204": + // description: response when updating an org-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Org.Organization.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +var _ actions_service.API = new(Action) + +// Action implements actions_service.API +type Action struct{} + +// NewAction creates a new Action service +func NewAction() actions_service.API { + return Action{} +} diff --git a/routers/api/v1/org/avatar.go b/routers/api/v1/org/avatar.go new file mode 100644 index 0000000..f11eb6c --- /dev/null +++ b/routers/api/v1/org/avatar.go @@ -0,0 +1,80 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "encoding/base64" + "net/http" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +// UpdateAvatarupdates the Avatar of an Organisation +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/avatar organization orgUpdateAvatar + // --- + // summary: Update Avatar + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateUserAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.UpdateUserAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = user_service.UploadAvatar(ctx, ctx.Org.Organization.AsUser(), content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteAvatar deletes the Avatar of an Organisation +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/avatar organization orgDeleteAvatar + // --- + // summary: Delete Avatar + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + err := user_service.DeleteAvatar(ctx, ctx.Org.Organization.AsUser()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/org/hook.go b/routers/api/v1/org/hook.go new file mode 100644 index 0000000..c1dc051 --- /dev/null +++ b/routers/api/v1/org/hook.go @@ -0,0 +1,189 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +// ListHooks list an organziation's webhooks +func ListHooks(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/hooks organization orgListHooks + // --- + // summary: List an organization's webhooks + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/HookList" + // "404": + // "$ref": "#/responses/notFound" + + utils.ListOwnerHooks( + ctx, + ctx.ContextUser, + ) +} + +// GetHook get an organization's hook by id +func GetHook(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/hooks/{id} organization orgGetHook + // --- + // summary: Get a hook + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the hook to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" + + hook, err := utils.GetOwnerHook(ctx, ctx.ContextUser.ID, ctx.ParamsInt64("id")) + if err != nil { + return + } + + apiHook, err := webhook_service.ToHook(ctx.ContextUser.HomeLink(), hook) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusOK, apiHook) +} + +// CreateHook create a hook for an organization +func CreateHook(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/hooks organization orgCreateHook + // --- + // summary: Create a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateHookOption" + // responses: + // "201": + // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" + + utils.AddOwnerHook( + ctx, + ctx.ContextUser, + web.GetForm(ctx).(*api.CreateHookOption), + ) +} + +// EditHook modify a hook of an organization +func EditHook(ctx *context.APIContext) { + // swagger:operation PATCH /orgs/{org}/hooks/{id} organization orgEditHook + // --- + // summary: Update a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the hook to update + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditHookOption" + // responses: + // "200": + // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" + + utils.EditOwnerHook( + ctx, + ctx.ContextUser, + web.GetForm(ctx).(*api.EditHookOption), + ctx.ParamsInt64("id"), + ) +} + +// DeleteHook delete a hook of an organization +func DeleteHook(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/hooks/{id} organization orgDeleteHook + // --- + // summary: Delete a hook + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the hook to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + utils.DeleteOwnerHook( + ctx, + ctx.ContextUser, + ctx.ParamsInt64("id"), + ) +} diff --git a/routers/api/v1/org/label.go b/routers/api/v1/org/label.go new file mode 100644 index 0000000..b5ec54c --- /dev/null +++ b/routers/api/v1/org/label.go @@ -0,0 +1,258 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "strconv" + "strings" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/label" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListLabels list all the labels of an organization +func ListLabels(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/labels organization orgListLabels + // --- + // summary: List an organization's labels + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/LabelList" + // "404": + // "$ref": "#/responses/notFound" + + labels, err := issues_model.GetLabelsByOrgID(ctx, ctx.Org.Organization.ID, ctx.FormString("sort"), utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByOrgID", err) + return + } + + count, err := issues_model.CountLabelsByOrgID(ctx, ctx.Org.Organization.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToLabelList(labels, nil, ctx.Org.Organization.AsUser())) +} + +// CreateLabel create a label for a repository +func CreateLabel(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/labels organization orgCreateLabel + // --- + // summary: Create a label for an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateLabelOption" + // responses: + // "201": + // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.CreateLabelOption) + form.Color = strings.Trim(form.Color, " ") + color, err := label.NormalizeColor(form.Color) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "Color", err) + return + } + form.Color = color + + label := &issues_model.Label{ + Name: form.Name, + Exclusive: form.Exclusive, + Color: form.Color, + OrgID: ctx.Org.Organization.ID, + Description: form.Description, + } + if err := issues_model.NewLabel(ctx, label); err != nil { + ctx.Error(http.StatusInternalServerError, "NewLabel", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToLabel(label, nil, ctx.Org.Organization.AsUser())) +} + +// GetLabel get label by organization and label id +func GetLabel(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/labels/{id} organization orgGetLabel + // --- + // summary: Get a single label + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the label to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" + + var ( + label *issues_model.Label + err error + ) + strID := ctx.Params(":id") + if intID, err2 := strconv.ParseInt(strID, 10, 64); err2 != nil { + label, err = issues_model.GetLabelInOrgByName(ctx, ctx.Org.Organization.ID, strID) + } else { + label, err = issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, intID) + } + if err != nil { + if issues_model.IsErrOrgLabelNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetLabelByOrgID", err) + } + return + } + + ctx.JSON(http.StatusOK, convert.ToLabel(label, nil, ctx.Org.Organization.AsUser())) +} + +// EditLabel modify a label for an Organization +func EditLabel(ctx *context.APIContext) { + // swagger:operation PATCH /orgs/{org}/labels/{id} organization orgEditLabel + // --- + // summary: Update a label + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the label to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditLabelOption" + // responses: + // "200": + // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.EditLabelOption) + l, err := issues_model.GetLabelInOrgByID(ctx, ctx.Org.Organization.ID, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.IsErrOrgLabelNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err) + } + return + } + + if form.Name != nil { + l.Name = *form.Name + } + if form.Exclusive != nil { + l.Exclusive = *form.Exclusive + } + if form.Color != nil { + color, err := label.NormalizeColor(*form.Color) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "Color", err) + return + } + l.Color = color + } + if form.Description != nil { + l.Description = *form.Description + } + l.SetArchived(form.IsArchived != nil && *form.IsArchived) + if err := issues_model.UpdateLabel(ctx, l); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToLabel(l, nil, ctx.Org.Organization.AsUser())) +} + +// DeleteLabel delete a label for an organization +func DeleteLabel(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/labels/{id} organization orgDeleteLabel + // --- + // summary: Delete a label + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: id + // in: path + // description: id of the label to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + if err := issues_model.DeleteLabel(ctx, ctx.Org.Organization.ID, ctx.ParamsInt64(":id")); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteLabel", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/org/member.go b/routers/api/v1/org/member.go new file mode 100644 index 0000000..fb66d4c --- /dev/null +++ b/routers/api/v1/org/member.go @@ -0,0 +1,325 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "net/url" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/user" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// listMembers list an organization's members +func listMembers(ctx *context.APIContext, publicOnly bool) { + opts := &organization.FindOrgMembersOpts{ + OrgID: ctx.Org.Organization.ID, + PublicOnly: publicOnly, + ListOptions: utils.GetListOptions(ctx), + } + + count, err := organization.CountOrgMembers(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + members, _, err := organization.FindOrgMembers(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiMembers := make([]*api.User, len(members)) + for i, member := range members { + apiMembers[i] = convert.ToUser(ctx, member, ctx.Doer) + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiMembers) +} + +// ListMembers list an organization's members +func ListMembers(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/members organization orgListMembers + // --- + // summary: List an organization's members + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + publicOnly := true + if ctx.Doer != nil { + isMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) + return + } + publicOnly = !isMember && !ctx.Doer.IsAdmin + } + listMembers(ctx, publicOnly) +} + +// ListPublicMembers list an organization's public members +func ListPublicMembers(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/public_members organization orgListPublicMembers + // --- + // summary: List an organization's public members + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + listMembers(ctx, true) +} + +// IsMember check if a user is a member of an organization +func IsMember(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/members/{username} organization orgIsMember + // --- + // summary: Check if a user is a member of an organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // description: user is a member + // "303": + // description: redirection to /orgs/{org}/public_members/{username} + // "404": + // description: user is not a member + + userToCheck := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + if ctx.Doer != nil { + userIsMember, err := ctx.Org.Organization.IsOrgMember(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) + return + } else if userIsMember || ctx.Doer.IsAdmin { + userToCheckIsMember, err := ctx.Org.Organization.IsOrgMember(ctx, userToCheck.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) + } else if userToCheckIsMember { + ctx.Status(http.StatusNoContent) + } else { + ctx.NotFound() + } + return + } else if ctx.Doer.ID == userToCheck.ID { + ctx.NotFound() + return + } + } + + redirectURL := setting.AppSubURL + "/api/v1/orgs/" + url.PathEscape(ctx.Org.Organization.Name) + "/public_members/" + url.PathEscape(userToCheck.Name) + ctx.Redirect(redirectURL) +} + +// IsPublicMember check if a user is a public member of an organization +func IsPublicMember(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/public_members/{username} organization orgIsPublicMember + // --- + // summary: Check if a user is a public member of an organization + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // description: user is a public member + // "404": + // description: user is not a public member + + userToCheck := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + is, err := organization.IsPublicMembership(ctx, ctx.Org.Organization.ID, userToCheck.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsPublicMembership", err) + return + } + if is { + ctx.Status(http.StatusNoContent) + } else { + ctx.NotFound() + } +} + +// PublicizeMember make a member's membership public +func PublicizeMember(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/public_members/{username} organization orgPublicizeMember + // --- + // summary: Publicize a user's membership + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // description: membership publicized + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + userToPublicize := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + if userToPublicize.ID != ctx.Doer.ID { + ctx.Error(http.StatusForbidden, "", "Cannot publicize another member") + return + } + err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToPublicize.ID, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeOrgUserStatus", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// ConcealMember make a member's membership not public +func ConcealMember(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/public_members/{username} organization orgConcealMember + // --- + // summary: Conceal a user's membership + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + userToConceal := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + if userToConceal.ID != ctx.Doer.ID { + ctx.Error(http.StatusForbidden, "", "Cannot conceal another member") + return + } + err := organization.ChangeOrgUserStatus(ctx, ctx.Org.Organization.ID, userToConceal.ID, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeOrgUserStatus", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// DeleteMember remove a member from an organization +func DeleteMember(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/members/{username} organization orgDeleteMember + // --- + // summary: Remove a member from an organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // description: member removed + // "404": + // "$ref": "#/responses/notFound" + + member := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + if err := models.RemoveOrgUser(ctx, ctx.Org.Organization.ID, member.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "RemoveOrgUser", err) + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go new file mode 100644 index 0000000..95c8c25 --- /dev/null +++ b/routers/api/v1/org/org.go @@ -0,0 +1,555 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "fmt" + "net/http" + + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/user" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/org" + user_service "code.gitea.io/gitea/services/user" +) + +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, + } + orgs, maxResults, err := db.FindAndCount[organization.Organization](ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "db.FindAndCount[organization.Organization]", err) + return + } + + apiOrgs := make([]*api.Organization, len(orgs)) + for i := range orgs { + apiOrgs[i] = convert.ToOrganization(ctx, orgs[i]) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &apiOrgs) +} + +// ListMyOrgs list all my orgs +func ListMyOrgs(ctx *context.APIContext) { + // swagger:operation GET /user/orgs organization orgListCurrentUserOrgs + // --- + // summary: List the current user's organizations + // produces: + // - application/json + // parameters: + // - 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/OrganizationList" + // "404": + // "$ref": "#/responses/notFound" + + listUserOrgs(ctx, ctx.Doer) +} + +// ListUserOrgs list user's orgs +func ListUserOrgs(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/orgs organization orgListUserOrgs + // --- + // summary: List a user's organizations + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - 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/OrganizationList" + // "404": + // "$ref": "#/responses/notFound" + + listUserOrgs(ctx, ctx.ContextUser) +} + +// GetUserOrgsPermissions get user permissions in organization +func GetUserOrgsPermissions(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/orgs/{org}/permissions organization orgGetUserPermissions + // --- + // summary: Get user permissions in organization + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/OrganizationPermissions" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + var o *user_model.User + if o = user.GetUserByParamsName(ctx, ":org"); o == nil { + return + } + + op := api.OrganizationPermissions{} + + if !organization.HasOrgOrUserVisible(ctx, o, ctx.ContextUser) { + ctx.NotFound("HasOrgOrUserVisible", nil) + return + } + + org := organization.OrgFromUser(o) + authorizeLevel, err := org.GetOrgUserMaxAuthorizeLevel(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetOrgUserAuthorizeLevel", err) + return + } + + if authorizeLevel > perm.AccessModeNone { + op.CanRead = true + } + if authorizeLevel > perm.AccessModeRead { + op.CanWrite = true + } + if authorizeLevel > perm.AccessModeWrite { + op.IsAdmin = true + } + if authorizeLevel > perm.AccessModeAdmin { + op.IsOwner = true + } + + op.CanCreateRepository, err = org.CanCreateOrgRepo(ctx, ctx.ContextUser.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CanCreateOrgRepo", err) + return + } + + ctx.JSON(http.StatusOK, op) +} + +// GetAll return list of all public organizations +func GetAll(ctx *context.APIContext) { + // swagger:operation Get /orgs organization orgGetAll + // --- + // summary: Get list of organizations + // produces: + // - application/json + // parameters: + // - 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/OrganizationList" + + vMode := []api.VisibleType{api.VisibleTypePublic} + if ctx.IsSigned && !ctx.PublicOnly { + vMode = append(vMode, api.VisibleTypeLimited) + if ctx.Doer.IsAdmin { + vMode = append(vMode, api.VisibleTypePrivate) + } + } + + listOptions := utils.GetListOptions(ctx) + + publicOrgs, maxResults, err := user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + ListOptions: listOptions, + Type: user_model.UserTypeOrganization, + OrderBy: db.SearchOrderByAlphabetically, + Visible: vMode, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchOrganizations", err) + return + } + orgs := make([]*api.Organization, len(publicOrgs)) + for i := range publicOrgs { + orgs[i] = convert.ToOrganization(ctx, organization.OrgFromUser(publicOrgs[i])) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &orgs) +} + +// Create api for create organization +func Create(ctx *context.APIContext) { + // swagger:operation POST /orgs organization orgCreate + // --- + // summary: Create an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: organization + // in: body + // required: true + // schema: { "$ref": "#/definitions/CreateOrgOption" } + // responses: + // "201": + // "$ref": "#/responses/Organization" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.CreateOrgOption) + if !ctx.Doer.CanCreateOrganization() { + ctx.Error(http.StatusForbidden, "Create organization not allowed", nil) + return + } + + visibility := api.VisibleTypePublic + if form.Visibility != "" { + visibility = api.VisibilityModes[form.Visibility] + } + + org := &organization.Organization{ + Name: form.UserName, + FullName: form.FullName, + Email: form.Email, + Description: form.Description, + Website: form.Website, + Location: form.Location, + IsActive: true, + Type: user_model.UserTypeOrganization, + Visibility: visibility, + RepoAdminChangeTeamAccess: form.RepoAdminChangeTeamAccess, + } + if err := organization.CreateOrganization(ctx, org, ctx.Doer); err != nil { + if user_model.IsErrUserAlreadyExist(err) || + db.IsErrNameReserved(err) || + db.IsErrNameCharsNotAllowed(err) || + db.IsErrNamePatternNotAllowed(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrganization", err) + } + return + } + + ctx.JSON(http.StatusCreated, convert.ToOrganization(ctx, org)) +} + +// Get get an organization +func Get(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org} organization orgGet + // --- + // summary: Get an organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Organization" + // "404": + // "$ref": "#/responses/notFound" + + if !organization.HasOrgOrUserVisible(ctx, ctx.Org.Organization.AsUser(), ctx.Doer) { + ctx.NotFound("HasOrgOrUserVisible", nil) + return + } + + org := convert.ToOrganization(ctx, ctx.Org.Organization) + + // Don't show Mail, when User is not logged in + if ctx.Doer == nil { + org.Email = "" + } + + ctx.JSON(http.StatusOK, org) +} + +// Edit change an organization's information +func Edit(ctx *context.APIContext) { + // swagger:operation PATCH /orgs/{org} organization orgEdit + // --- + // summary: Edit an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization to edit + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/EditOrgOption" + // responses: + // "200": + // "$ref": "#/responses/Organization" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditOrgOption) + + if form.Email != "" { + if err := user_service.ReplacePrimaryEmailAddress(ctx, ctx.Org.Organization.AsUser(), form.Email); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplacePrimaryEmailAddress", err) + return + } + } + + opts := &user_service.UpdateOptions{ + FullName: optional.Some(form.FullName), + Description: optional.Some(form.Description), + Website: optional.Some(form.Website), + Location: optional.Some(form.Location), + Visibility: optional.FromNonDefault(api.VisibilityModes[form.Visibility]), + RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess), + } + if err := user_service.UpdateUser(ctx, ctx.Org.Organization.AsUser(), opts); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateUser", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToOrganization(ctx, ctx.Org.Organization)) +} + +// Delete an organization +func Delete(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org} organization orgDelete + // --- + // summary: Delete an organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: organization that is to be deleted + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + if err := org.DeleteOrganization(ctx, ctx.Org.Organization, false); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteOrganization", err) + return + } + ctx.Status(http.StatusNoContent) +} + +func ListOrgActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/activities/feeds organization orgListActivityFeeds + // --- + // summary: List an organization's activity feeds + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - name: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - 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/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + includePrivate := false + if ctx.IsSigned { + if ctx.Doer.IsAdmin { + includePrivate = true + } else { + org := organization.OrgFromUser(ctx.ContextUser) + isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) + return + } + includePrivate = isMember + } + } + + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: includePrivate, + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} + +// ListBlockedUsers list the organization's blocked users. +func ListBlockedUsers(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/list_blocked organization orgListBlockedUsers + // --- + // summary: List the organization's blocked users + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - 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/BlockedUserList" + + utils.ListUserBlockedUsers(ctx, ctx.ContextUser) +} + +// BlockUser blocks a user from the organization. +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/block/{username} organization orgBlockUser + // --- + // summary: Blocks a user from the organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.BlockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser) +} + +// UnblockUser unblocks a user from the organization. +func UnblockUser(ctx *context.APIContext) { + // swagger:operation PUT /orgs/{org}/unblock/{username} organization orgUnblockUser + // --- + // summary: Unblock a user from the organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the org + // type: string + // required: true + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.UnblockUser(ctx, ctx.Org.Organization.AsUser(), ctx.ContextUser) +} diff --git a/routers/api/v1/org/quota.go b/routers/api/v1/org/quota.go new file mode 100644 index 0000000..57c41f5 --- /dev/null +++ b/routers/api/v1/org/quota.go @@ -0,0 +1,155 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// GetQuota returns the quota information for a given organization +func GetQuota(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/quota organization orgGetQuota + // --- + // summary: Get quota information for an organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/QuotaInfo" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + shared.GetQuota(ctx, ctx.Org.Organization.ID) +} + +// CheckQuota returns whether the organization in context is over the subject quota +func CheckQuota(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/quota/check organization orgCheckQuota + // --- + // summary: Check if the organization is over quota for a given subject + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/boolean" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + shared.CheckQuota(ctx, ctx.Org.Organization.ID) +} + +// ListQuotaAttachments lists attachments affecting the organization's quota +func ListQuotaAttachments(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/quota/attachments organization orgListQuotaAttachments + // --- + // summary: List the attachments affecting the organization's quota + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/QuotaUsedAttachmentList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + shared.ListQuotaAttachments(ctx, ctx.Org.Organization.ID) +} + +// ListQuotaPackages lists packages affecting the organization's quota +func ListQuotaPackages(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/quota/packages organization orgListQuotaPackages + // --- + // summary: List the packages affecting the organization's quota + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/QuotaUsedPackageList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + shared.ListQuotaPackages(ctx, ctx.Org.Organization.ID) +} + +// ListQuotaArtifacts lists artifacts affecting the organization's quota +func ListQuotaArtifacts(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/quota/artifacts organization orgListQuotaArtifacts + // --- + // summary: List the artifacts affecting the organization's quota + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/QuotaUsedArtifactList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + shared.ListQuotaArtifacts(ctx, ctx.Org.Organization.ID) +} diff --git a/routers/api/v1/org/team.go b/routers/api/v1/org/team.go new file mode 100644 index 0000000..da4fc13 --- /dev/null +++ b/routers/api/v1/org/team.go @@ -0,0 +1,887 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models" + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/user" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" +) + +// ListTeams list all the teams of an organization +func ListTeams(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/teams organization orgListTeams + // --- + // summary: List an organization's teams + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/TeamList" + // "404": + // "$ref": "#/responses/notFound" + + teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{ + ListOptions: utils.GetListOptions(ctx), + OrgID: ctx.Org.Organization.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadTeams", err) + return + } + + apiTeams, err := convert.ToTeams(ctx, teams, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ConvertToTeams", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiTeams) +} + +// ListUserTeams list all the teams a user belongs to +func ListUserTeams(ctx *context.APIContext) { + // swagger:operation GET /user/teams user userListTeams + // --- + // summary: List all the teams a user belongs to + // produces: + // - application/json + // parameters: + // - 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/TeamList" + + teams, count, err := organization.SearchTeam(ctx, &organization.SearchTeamOptions{ + ListOptions: utils.GetListOptions(ctx), + UserID: ctx.Doer.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserTeams", err) + return + } + + apiTeams, err := convert.ToTeams(ctx, teams, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ConvertToTeams", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiTeams) +} + +// GetTeam api for get a team +func GetTeam(ctx *context.APIContext) { + // swagger:operation GET /teams/{id} organization orgGetTeam + // --- + // summary: Get a team + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" + + apiTeam, err := convert.ToTeam(ctx, ctx.Org.Team, true) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, apiTeam) +} + +func attachTeamUnits(team *organization.Team, units []string) { + unitTypes, _ := unit_model.FindUnitTypes(units...) + team.Units = make([]*organization.TeamUnit, 0, len(units)) + for _, tp := range unitTypes { + team.Units = append(team.Units, &organization.TeamUnit{ + OrgID: team.OrgID, + Type: tp, + AccessMode: team.AccessMode, + }) + } +} + +func convertUnitsMap(unitsMap map[string]string) map[unit_model.Type]perm.AccessMode { + res := make(map[unit_model.Type]perm.AccessMode, len(unitsMap)) + for unitKey, p := range unitsMap { + res[unit_model.TypeFromKey(unitKey)] = perm.ParseAccessMode(p) + } + return res +} + +func attachTeamUnitsMap(team *organization.Team, unitsMap map[string]string) { + team.Units = make([]*organization.TeamUnit, 0, len(unitsMap)) + for unitKey, p := range unitsMap { + team.Units = append(team.Units, &organization.TeamUnit{ + OrgID: team.OrgID, + Type: unit_model.TypeFromKey(unitKey), + AccessMode: perm.ParseAccessMode(p), + }) + } +} + +func attachAdminTeamUnits(team *organization.Team) { + team.Units = make([]*organization.TeamUnit, 0, len(unit_model.AllRepoUnitTypes)) + for _, ut := range unit_model.AllRepoUnitTypes { + up := perm.AccessModeAdmin + if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki { + up = perm.AccessModeRead + } + team.Units = append(team.Units, &organization.TeamUnit{ + OrgID: team.OrgID, + Type: ut, + AccessMode: up, + }) + } +} + +// CreateTeam api for create a team +func CreateTeam(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/teams organization orgCreateTeam + // --- + // summary: Create a team + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateTeamOption" + // responses: + // "201": + // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.CreateTeamOption) + p := perm.ParseAccessMode(form.Permission) + if p < perm.AccessModeAdmin && len(form.UnitsMap) > 0 { + p = unit_model.MinUnitAccessMode(convertUnitsMap(form.UnitsMap)) + } + team := &organization.Team{ + OrgID: ctx.Org.Organization.ID, + Name: form.Name, + Description: form.Description, + IncludesAllRepositories: form.IncludesAllRepositories, + CanCreateOrgRepo: form.CanCreateOrgRepo, + AccessMode: p, + } + + if team.AccessMode < perm.AccessModeAdmin { + if len(form.UnitsMap) > 0 { + attachTeamUnitsMap(team, form.UnitsMap) + } else if len(form.Units) > 0 { + attachTeamUnits(team, form.Units) + } else { + ctx.Error(http.StatusInternalServerError, "getTeamUnits", errors.New("units permission should not be empty")) + return + } + } else { + attachAdminTeamUnits(team) + } + + if err := models.NewTeam(ctx, team); err != nil { + if organization.IsErrTeamAlreadyExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "NewTeam", err) + } + return + } + + apiTeam, err := convert.ToTeam(ctx, team, true) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusCreated, apiTeam) +} + +// EditTeam api for edit a team +func EditTeam(ctx *context.APIContext) { + // swagger:operation PATCH /teams/{id} organization orgEditTeam + // --- + // summary: Edit a team + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team to edit + // type: integer + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditTeamOption" + // responses: + // "200": + // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditTeamOption) + team := ctx.Org.Team + if err := team.LoadUnits(ctx); err != nil { + ctx.InternalServerError(err) + return + } + + if form.CanCreateOrgRepo != nil { + team.CanCreateOrgRepo = team.IsOwnerTeam() || *form.CanCreateOrgRepo + } + + if len(form.Name) > 0 { + team.Name = form.Name + } + + if form.Description != nil { + team.Description = *form.Description + } + + isAuthChanged := false + isIncludeAllChanged := false + if !team.IsOwnerTeam() && len(form.Permission) != 0 { + // Validate permission level. + p := perm.ParseAccessMode(form.Permission) + if p < perm.AccessModeAdmin && len(form.UnitsMap) > 0 { + p = unit_model.MinUnitAccessMode(convertUnitsMap(form.UnitsMap)) + } + + if team.AccessMode != p { + isAuthChanged = true + team.AccessMode = p + } + + if form.IncludesAllRepositories != nil { + isIncludeAllChanged = true + team.IncludesAllRepositories = *form.IncludesAllRepositories + } + } + + if team.AccessMode < perm.AccessModeAdmin { + if len(form.UnitsMap) > 0 { + attachTeamUnitsMap(team, form.UnitsMap) + } else if len(form.Units) > 0 { + attachTeamUnits(team, form.Units) + } + } else { + attachAdminTeamUnits(team) + } + + if err := models.UpdateTeam(ctx, team, isAuthChanged, isIncludeAllChanged); err != nil { + ctx.Error(http.StatusInternalServerError, "EditTeam", err) + return + } + + apiTeam, err := convert.ToTeam(ctx, team) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusOK, apiTeam) +} + +// DeleteTeam api for delete a team +func DeleteTeam(ctx *context.APIContext) { + // swagger:operation DELETE /teams/{id} organization orgDeleteTeam + // --- + // summary: Delete a team + // parameters: + // - name: id + // in: path + // description: id of the team to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // description: team deleted + // "404": + // "$ref": "#/responses/notFound" + + if err := models.DeleteTeam(ctx, ctx.Org.Team); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteTeam", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// GetTeamMembers api for get a team's members +func GetTeamMembers(ctx *context.APIContext) { + // swagger:operation GET /teams/{id}/members organization orgListTeamMembers + // --- + // summary: List a team's members + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Team.OrgID, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err) + return + } else if !isMember && !ctx.Doer.IsAdmin { + ctx.NotFound() + return + } + + teamMembers, err := organization.GetTeamMembers(ctx, &organization.SearchMembersOptions{ + ListOptions: utils.GetListOptions(ctx), + TeamID: ctx.Org.Team.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTeamMembers", err) + return + } + + members := make([]*api.User, len(teamMembers)) + for i, member := range teamMembers { + members[i] = convert.ToUser(ctx, member, ctx.Doer) + } + + ctx.SetTotalCountHeader(int64(ctx.Org.Team.NumMembers)) + ctx.JSON(http.StatusOK, members) +} + +// GetTeamMember api for get a particular member of team +func GetTeamMember(ctx *context.APIContext) { + // swagger:operation GET /teams/{id}/members/{username} organization orgListTeamMember + // --- + // summary: List a particular member of team + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: username + // in: path + // description: username of the member to list + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/User" + // "404": + // "$ref": "#/responses/notFound" + + u := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + teamID := ctx.ParamsInt64("teamid") + isTeamMember, err := organization.IsUserInTeams(ctx, u.ID, []int64{teamID}) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsUserInTeams", err) + return + } else if !isTeamMember { + ctx.NotFound() + return + } + ctx.JSON(http.StatusOK, convert.ToUser(ctx, u, ctx.Doer)) +} + +// AddTeamMember api for add a member to a team +func AddTeamMember(ctx *context.APIContext) { + // swagger:operation PUT /teams/{id}/members/{username} organization orgAddTeamMember + // --- + // summary: Add a team member + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: username + // in: path + // description: username of the user to add + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + u := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + if err := models.AddTeamMember(ctx, ctx.Org.Team, u.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "AddMember", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// RemoveTeamMember api for remove one member from a team +func RemoveTeamMember(ctx *context.APIContext) { + // swagger:operation DELETE /teams/{id}/members/{username} organization orgRemoveTeamMember + // --- + // summary: Remove a team member + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: username + // in: path + // description: username of the user to remove + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + u := user.GetUserByParams(ctx) + if ctx.Written() { + return + } + + if err := models.RemoveTeamMember(ctx, ctx.Org.Team, u.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "RemoveTeamMember", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// GetTeamRepos api for get a team's repos +func GetTeamRepos(ctx *context.APIContext) { + // swagger:operation GET /teams/{id}/repos organization orgListTeamRepos + // --- + // summary: List a team's repos + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - 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/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" + + team := ctx.Org.Team + teamRepos, err := organization.GetTeamRepositories(ctx, &organization.SearchTeamRepoOptions{ + ListOptions: utils.GetListOptions(ctx), + TeamID: team.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTeamRepos", err) + return + } + repos := make([]*api.Repository, len(teamRepos)) + for i, repo := range teamRepos { + permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTeamRepos", err) + return + } + repos[i] = convert.ToRepo(ctx, repo, permission) + } + ctx.SetTotalCountHeader(int64(team.NumRepos)) + ctx.JSON(http.StatusOK, repos) +} + +// GetTeamRepo api for get a particular repo of team +func GetTeamRepo(ctx *context.APIContext) { + // swagger:operation GET /teams/{id}/repos/{org}/{repo} organization orgListTeamRepo + // --- + // summary: List a particular repo of team + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: org + // in: path + // description: organization that owns the repo to list + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to list + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Repository" + // "404": + // "$ref": "#/responses/notFound" + + repo := getRepositoryByParams(ctx) + if ctx.Written() { + return + } + + if !organization.HasTeamRepo(ctx, ctx.Org.Team.OrgID, ctx.Org.Team.ID, repo.ID) { + ctx.NotFound() + return + } + + permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTeamRepos", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission)) +} + +// getRepositoryByParams get repository by a team's organization ID and repo name +func getRepositoryByParams(ctx *context.APIContext) *repo_model.Repository { + repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Team.OrgID, ctx.Params(":reponame")) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByName", err) + } + return nil + } + return repo +} + +// AddTeamRepository api for adding a repository to a team +func AddTeamRepository(ctx *context.APIContext) { + // swagger:operation PUT /teams/{id}/repos/{org}/{repo} organization orgAddTeamRepository + // --- + // summary: Add a repository to a team + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: org + // in: path + // description: organization that owns the repo to add + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to add + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + repo := getRepositoryByParams(ctx) + if ctx.Written() { + return + } + if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil { + ctx.Error(http.StatusInternalServerError, "AccessLevel", err) + return + } else if access < perm.AccessModeAdmin { + ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository") + return + } + if err := org_service.TeamAddRepository(ctx, ctx.Org.Team, repo); err != nil { + ctx.Error(http.StatusInternalServerError, "TeamAddRepository", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// RemoveTeamRepository api for removing a repository from a team +func RemoveTeamRepository(ctx *context.APIContext) { + // swagger:operation DELETE /teams/{id}/repos/{org}/{repo} organization orgRemoveTeamRepository + // --- + // summary: Remove a repository from a team + // description: This does not delete the repository, it only removes the + // repository from the team. + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: org + // in: path + // description: organization that owns the repo to remove + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to remove + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + repo := getRepositoryByParams(ctx) + if ctx.Written() { + return + } + if access, err := access_model.AccessLevel(ctx, ctx.Doer, repo); err != nil { + ctx.Error(http.StatusInternalServerError, "AccessLevel", err) + return + } else if access < perm.AccessModeAdmin { + ctx.Error(http.StatusForbidden, "", "Must have admin-level access to the repository") + return + } + if err := repo_service.RemoveRepositoryFromTeam(ctx, ctx.Org.Team, repo.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "RemoveRepository", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// SearchTeam api for searching teams +func SearchTeam(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/teams/search organization teamSearch + // --- + // summary: Search for teams within an organization + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: q + // in: query + // description: keywords to search + // type: string + // - name: include_desc + // in: query + // description: include search within team description (defaults to true) + // type: boolean + // - 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": + // description: "SearchResults of a successful search" + // schema: + // type: object + // properties: + // ok: + // type: boolean + // data: + // type: array + // items: + // "$ref": "#/definitions/Team" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + + opts := &organization.SearchTeamOptions{ + Keyword: ctx.FormTrim("q"), + OrgID: ctx.Org.Organization.ID, + IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"), + ListOptions: listOptions, + } + + // Only admin is allowed to search for all teams + if !ctx.Doer.IsAdmin { + opts.UserID = ctx.Doer.ID + } + + teams, maxResults, err := organization.SearchTeam(ctx, opts) + if err != nil { + log.Error("SearchTeam failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": "SearchTeam internal failure", + }) + return + } + + apiTeams, err := convert.ToTeams(ctx, teams, false) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, map[string]any{ + "ok": true, + "data": apiTeams, + }) +} + +func ListTeamActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /teams/{id}/activities/feeds organization orgListTeamActivityFeeds + // --- + // summary: List a team's activity feeds + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the team + // type: integer + // format: int64 + // required: true + // - name: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - 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/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedTeam: ctx.Org.Team, + Actor: ctx.Doer, + IncludePrivate: true, + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} diff --git a/routers/api/v1/packages/package.go b/routers/api/v1/packages/package.go new file mode 100644 index 0000000..b38aa13 --- /dev/null +++ b/routers/api/v1/packages/package.go @@ -0,0 +1,215 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package packages + +import ( + "net/http" + + "code.gitea.io/gitea/models/packages" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + packages_service "code.gitea.io/gitea/services/packages" +) + +// ListPackages gets all packages of an owner +func ListPackages(ctx *context.APIContext) { + // swagger:operation GET /packages/{owner} package listPackages + // --- + // summary: Gets all packages of an owner + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the packages + // type: string + // required: true + // - 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 + // - name: type + // in: query + // description: package type filter + // type: string + // enum: [alpine, cargo, chef, composer, conan, conda, container, cran, debian, generic, go, helm, maven, npm, nuget, pub, pypi, rpm, rubygems, swift, vagrant] + // - name: q + // in: query + // description: name filter + // type: string + // responses: + // "200": + // "$ref": "#/responses/PackageList" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + + packageType := ctx.FormTrim("type") + query := ctx.FormTrim("q") + + pvs, count, err := packages.SearchVersions(ctx, &packages.PackageSearchOptions{ + OwnerID: ctx.Package.Owner.ID, + Type: packages.Type(packageType), + Name: packages.SearchValue{Value: query}, + IsInternal: optional.Some(false), + Paginator: &listOptions, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchVersions", err) + return + } + + pds, err := packages.GetPackageDescriptors(ctx, pvs) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetPackageDescriptors", err) + return + } + + apiPackages := make([]*api.Package, 0, len(pds)) + for _, pd := range pds { + apiPackage, err := convert.ToPackage(ctx, pd, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "Error converting package for api", err) + return + } + apiPackages = append(apiPackages, apiPackage) + } + + ctx.SetLinkHeader(int(count), listOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiPackages) +} + +// GetPackage gets a package +func GetPackage(ctx *context.APIContext) { + // swagger:operation GET /packages/{owner}/{type}/{name}/{version} package getPackage + // --- + // summary: Gets a package + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true + // - name: version + // in: path + // description: version of the package + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Package" + // "404": + // "$ref": "#/responses/notFound" + + apiPackage, err := convert.ToPackage(ctx, ctx.Package.Descriptor, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "Error converting package for api", err) + return + } + + ctx.JSON(http.StatusOK, apiPackage) +} + +// DeletePackage deletes a package +func DeletePackage(ctx *context.APIContext) { + // swagger:operation DELETE /packages/{owner}/{type}/{name}/{version} package deletePackage + // --- + // summary: Delete a package + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true + // - name: version + // in: path + // description: version of the package + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + err := packages_service.RemovePackageVersion(ctx, ctx.Doer, ctx.Package.Descriptor.Version) + if err != nil { + ctx.Error(http.StatusInternalServerError, "RemovePackageVersion", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// ListPackageFiles gets all files of a package +func ListPackageFiles(ctx *context.APIContext) { + // swagger:operation GET /packages/{owner}/{type}/{name}/{version}/files package listPackageFiles + // --- + // summary: Gets all files of a package + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the package + // type: string + // required: true + // - name: type + // in: path + // description: type of the package + // type: string + // required: true + // - name: name + // in: path + // description: name of the package + // type: string + // required: true + // - name: version + // in: path + // description: version of the package + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/PackageFileList" + // "404": + // "$ref": "#/responses/notFound" + + apiPackageFiles := make([]*api.PackageFile, 0, len(ctx.Package.Descriptor.Files)) + for _, pfd := range ctx.Package.Descriptor.Files { + apiPackageFiles = append(apiPackageFiles, convert.ToPackageFile(pfd)) + } + + ctx.JSON(http.StatusOK, apiPackageFiles) +} diff --git a/routers/api/v1/repo/action.go b/routers/api/v1/repo/action.go new file mode 100644 index 0000000..0c7506b --- /dev/null +++ b/routers/api/v1/repo/action.go @@ -0,0 +1,653 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + secret_model "code.gitea.io/gitea/models/secret" + 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" + "code.gitea.io/gitea/services/convert" + secret_service "code.gitea.io/gitea/services/secrets" +) + +// ListActionsSecrets list an repo's actions secrets +func (Action) ListActionsSecrets(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/secrets repository repoListActionsSecrets + // --- + // summary: List an repo's actions secrets + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repository + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - 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/SecretList" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + + opts := &secret_model.FindSecretsOptions{ + RepoID: repo.ID, + ListOptions: utils.GetListOptions(ctx), + } + + secrets, count, err := db.FindAndCount[secret_model.Secret](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiSecrets := make([]*api.Secret, len(secrets)) + for k, v := range secrets { + apiSecrets[k] = &api.Secret{ + Name: v.Name, + Created: v.CreatedUnix.AsTime(), + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiSecrets) +} + +// create or update one secret of the repository +func (Action) CreateOrUpdateSecret(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/secrets/{secretname} repository updateRepoSecret + // --- + // summary: Create or Update a secret value in a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repository + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrUpdateSecretOption" + // responses: + // "201": + // description: response when creating a secret + // "204": + // description: response when updating a secret + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + + opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) + + _, created, err := secret_service.CreateOrUpdateSecret(ctx, 0, repo.ID, ctx.Params("secretname"), opt.Data) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err) + } + return + } + + if created { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } +} + +// DeleteSecret delete one secret of the repository +func (Action) DeleteSecret(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/secrets/{secretname} repository deleteRepoSecret + // --- + // summary: Delete a secret in a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repository + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repository + // type: string + // required: true + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // responses: + // "204": + // description: delete one secret of the organization + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + + err := secret_service.DeleteSecretByName(ctx, 0, repo.ID, ctx.Params("secretname")) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteSecret", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// GetVariable get a repo-level variable +func (Action) GetVariable(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/variables/{variablename} repository getRepoVariable + // --- + // summary: Get a repo-level variable + // 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: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// DeleteVariable delete a repo-level variable +func (Action) DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/actions/variables/{variablename} repository deleteRepoVariable + // --- + // summary: Delete a repo-level variable + // 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: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, 0, ctx.Repo.Repository.ID, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateVariable create a repo-level variable +func (Action) CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/variables/{variablename} repository createRepoVariable + // --- + // summary: Create a repo-level variable + // 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: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // 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" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + repoID := ctx.Repo.Repository.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: repoID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, 0, repoID, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update a repo-level variable +func (Action) UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/actions/variables/{variablename} repository updateRepoVariable + // --- + // summary: Update a repo-level variable + // 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: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating a repo-level variable + // "204": + // description: response when updating a repo-level variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListVariables list repo-level variables +func (Action) ListVariables(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/variables repository getRepoVariablesList + // --- + // summary: Get repo-level variables list + // 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: 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/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + RepoID: ctx.Repo.Repository.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} + +// GetRegistrationToken returns the token to register repo runners +func (Action) GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/runners/registration-token repository repoGetRunnerRegistrationToken + // --- + // summary: Get a repository's actions runner registration token + // 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 + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, 0, ctx.Repo.Repository.ID) +} + +var _ actions_service.API = new(Action) + +// Action implements actions_service.API +type Action struct{} + +// NewAction creates a new Action service +func NewAction() actions_service.API { + return Action{} +} + +// ListActionTasks list all the actions of a repository +func ListActionTasks(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/actions/tasks repository ListActionTasks + // --- + // summary: List a repository's action tasks + // 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: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results, default maximum page size is 50 + // type: integer + // responses: + // "200": + // "$ref": "#/responses/TasksList" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/conflict" + // "422": + // "$ref": "#/responses/validationError" + + tasks, total, err := db.FindAndCount[actions_model.ActionTask](ctx, &actions_model.FindTaskOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListActionTasks", err) + return + } + + res := new(api.ActionTaskResponse) + res.TotalCount = total + + res.Entries = make([]*api.ActionTask, len(tasks)) + for i := range tasks { + convertedTask, err := convert.ToActionTask(ctx, tasks[i]) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ToActionTask", err) + return + } + res.Entries[i] = convertedTask + } + + ctx.JSON(http.StatusOK, &res) +} + +// DispatchWorkflow dispatches a workflow +func DispatchWorkflow(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/actions/workflows/{workflowname}/dispatches repository DispatchWorkflow + // --- + // summary: Dispatches a workflow + // consumes: + // - 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: workflowname + // in: path + // description: name of the workflow + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DispatchWorkflowOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.DispatchWorkflowOption) + name := ctx.Params("workflowname") + + if len(opt.Ref) == 0 { + ctx.Error(http.StatusBadRequest, "ref", "ref is empty") + return + } else if len(name) == 0 { + ctx.Error(http.StatusBadRequest, "workflowname", "workflow name is empty") + return + } + + workflow, err := actions_service.GetWorkflowFromCommit(ctx.Repo.GitRepo, opt.Ref, name) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetWorkflowFromCommit", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetWorkflowFromCommit", err) + } + return + } + + inputGetter := func(key string) string { + return opt.Inputs[key] + } + + if err := workflow.Dispatch(ctx, inputGetter, ctx.Repo.Repository, ctx.Doer); err != nil { + if actions_service.IsInputRequiredErr(err) { + ctx.Error(http.StatusBadRequest, "workflow.Dispatch", err) + } else { + ctx.Error(http.StatusInternalServerError, "workflow.Dispatch", err) + } + return + } + + ctx.JSON(http.StatusNoContent, nil) +} diff --git a/routers/api/v1/repo/avatar.go b/routers/api/v1/repo/avatar.go new file mode 100644 index 0000000..698337f --- /dev/null +++ b/routers/api/v1/repo/avatar.go @@ -0,0 +1,88 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "encoding/base64" + "net/http" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + repo_service "code.gitea.io/gitea/services/repository" +) + +// UpdateVatar updates the Avatar of an Repo +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/avatar repository repoUpdateAvatar + // --- + // summary: Update avatar + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateRepoAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.UpdateRepoAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = repo_service.UploadAvatar(ctx, ctx.Repo.Repository, content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateAvatar deletes the Avatar of an Repo +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/avatar repository repoDeleteAvatar + // --- + // summary: Delete avatar + // 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 + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/blob.go b/routers/api/v1/repo/blob.go new file mode 100644 index 0000000..3b11666 --- /dev/null +++ b/routers/api/v1/repo/blob.go @@ -0,0 +1,55 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +// GetBlob get the blob of a repository file. +func GetBlob(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/blobs/{sha} repository GetBlob + // --- + // summary: Gets the blob of a repository. + // 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: sha + // in: path + // description: sha of the commit + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/GitBlobResponse" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + sha := ctx.Params("sha") + if len(sha) == 0 { + ctx.Error(http.StatusBadRequest, "", "sha not provided") + return + } + + if blob, err := files_service.GetBlobBySHA(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, sha); err != nil { + ctx.Error(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 new file mode 100644 index 0000000..a468fd9 --- /dev/null +++ b/routers/api/v1/repo/branch.go @@ -0,0 +1,1019 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/optional" + repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" +) + +// GetBranch get a branch of a repository +func GetBranch(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/branches/{branch} repository repoGetBranch + // --- + // summary: Retrieve a specific branch from a repository, including its effective branch protection + // 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: branch + // in: path + // description: branch to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Branch" + // "404": + // "$ref": "#/responses/notFound" + + branchName := ctx.Params("*") + + branch, err := ctx.Repo.GitRepo.GetBranch(branchName) + if err != nil { + if git.IsErrBranchNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetBranch", err) + } + return + } + + c, err := branch.GetCommit() + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommit", err) + return + } + + branchProtection, err := git_model.GetFirstMatchProtectedBranchRule(ctx, ctx.Repo.Repository.ID, branchName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBranchProtection", err) + return + } + + br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, c, branchProtection, ctx.Doer, ctx.Repo.IsAdmin()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err) + return + } + + ctx.JSON(http.StatusOK, br) +} + +// DeleteBranch get a branch of a repository +func DeleteBranch(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/branches/{branch} repository repoDeleteBranch + // --- + // summary: Delete a specific branch from a repository + // 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: branch + // in: path + // description: branch to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + if ctx.Repo.Repository.IsEmpty { + ctx.Error(http.StatusNotFound, "", "Git Repository is empty.") + return + } + + if ctx.Repo.Repository.IsMirror { + ctx.Error(http.StatusForbidden, "", "Git Repository is a mirror.") + return + } + + branchName := ctx.Params("*") + + if ctx.Repo.Repository.IsEmpty { + ctx.Error(http.StatusForbidden, "", "Git Repository is empty.") + return + } + + // check whether branches of this repository has been synced + totalNumOfBranches, err := db.Count[git_model.Branch](ctx, git_model.FindBranchOptions{ + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CountBranches", err) + return + } + if totalNumOfBranches == 0 { // sync branches immediately because non-empty repository should have at least 1 branch + _, err = repo_module.SyncRepoBranches(ctx, ctx.Repo.Repository.ID, 0) + if err != nil { + ctx.ServerError("SyncRepoBranches", err) + return + } + } + + if ctx.Repo.Repository.IsMirror { + ctx.Error(http.StatusForbidden, "IsMirrored", fmt.Errorf("can not delete branch of an mirror repository")) + return + } + + if err := repo_service.DeleteBranch(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, branchName); err != nil { + switch { + case git.IsErrBranchNotExist(err): + ctx.NotFound(err) + case errors.Is(err, repo_service.ErrBranchIsDefault): + ctx.Error(http.StatusForbidden, "DefaultBranch", fmt.Errorf("can not delete default branch")) + case errors.Is(err, git_model.ErrBranchIsProtected): + ctx.Error(http.StatusForbidden, "IsProtectedBranch", fmt.Errorf("branch protected")) + default: + ctx.Error(http.StatusInternalServerError, "DeleteBranch", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateBranch creates a branch for a user's repository +func CreateBranch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/branches repository repoCreateBranch + // --- + // summary: Create a branch + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateBranchRepoOption" + // responses: + // "201": + // "$ref": "#/responses/Branch" + // "403": + // description: The branch is archived or a mirror. + // "404": + // description: The old branch does not exist. + // "409": + // description: The branch with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + + if ctx.Repo.Repository.IsEmpty { + ctx.Error(http.StatusNotFound, "", "Git Repository is empty.") + return + } + + if ctx.Repo.Repository.IsMirror { + ctx.Error(http.StatusForbidden, "", "Git Repository is a mirror.") + return + } + + opt := web.GetForm(ctx).(*api.CreateBranchRepoOption) + + var oldCommit *git.Commit + var err error + + if len(opt.OldRefName) > 0 { + oldCommit, err = ctx.Repo.GitRepo.GetCommit(opt.OldRefName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommit", err) + return + } + } else if len(opt.OldBranchName) > 0 { //nolint + if ctx.Repo.GitRepo.IsBranchExist(opt.OldBranchName) { //nolint + oldCommit, err = ctx.Repo.GitRepo.GetBranchCommit(opt.OldBranchName) //nolint + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBranchCommit", err) + return + } + } else { + ctx.Error(http.StatusNotFound, "", "The old branch does not exist") + return + } + } else { + oldCommit, err = ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBranchCommit", err) + return + } + } + + err = repo_service.CreateNewBranchFromCommit(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.GitRepo, oldCommit.ID.String(), opt.BranchName) + if err != nil { + if git_model.IsErrBranchNotExist(err) { + ctx.Error(http.StatusNotFound, "", "The old branch does not exist") + } else if models.IsErrTagAlreadyExists(err) { + ctx.Error(http.StatusConflict, "", "The branch with the same tag already exists.") + } else if git_model.IsErrBranchAlreadyExists(err) || git.IsErrPushOutOfDate(err) { + ctx.Error(http.StatusConflict, "", "The branch already exists.") + } else if git_model.IsErrBranchNameConflict(err) { + ctx.Error(http.StatusConflict, "", "The branch with the same name already exists.") + } else { + ctx.Error(http.StatusInternalServerError, "CreateNewBranchFromCommit", err) + } + return + } + + branch, err := ctx.Repo.GitRepo.GetBranch(opt.BranchName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBranch", err) + return + } + + commit, err := branch.GetCommit() + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommit", err) + return + } + + branchProtection, err := git_model.GetFirstMatchProtectedBranchRule(ctx, ctx.Repo.Repository.ID, branch.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBranchProtection", err) + return + } + + br, err := convert.ToBranch(ctx, ctx.Repo.Repository, branch.Name, commit, branchProtection, ctx.Doer, ctx.Repo.IsAdmin()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err) + return + } + + ctx.JSON(http.StatusCreated, br) +} + +// ListBranches list all the branches of a repository +func ListBranches(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/branches repository repoListBranches + // --- + // summary: List a repository's branches + // 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: 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/BranchList" + + var totalNumOfBranches int64 + var apiBranches []*api.Branch + + listOptions := utils.GetListOptions(ctx) + + if !ctx.Repo.Repository.IsEmpty { + if ctx.Repo.GitRepo == nil { + ctx.Error(http.StatusInternalServerError, "Load git repository failed", nil) + return + } + + branchOpts := git_model.FindBranchOptions{ + ListOptions: listOptions, + RepoID: ctx.Repo.Repository.ID, + IsDeletedBranch: optional.Some(false), + } + var err error + totalNumOfBranches, err = db.Count[git_model.Branch](ctx, branchOpts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CountBranches", err) + return + } + if totalNumOfBranches == 0 { // sync branches immediately because non-empty repository should have at least 1 branch + totalNumOfBranches, err = repo_module.SyncRepoBranches(ctx, ctx.Repo.Repository.ID, 0) + if err != nil { + ctx.ServerError("SyncRepoBranches", err) + return + } + } + + rules, err := git_model.FindRepoProtectedBranchRules(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindMatchedProtectedBranchRules", err) + return + } + + branches, err := db.Find[git_model.Branch](ctx, branchOpts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBranches", err) + return + } + + apiBranches = make([]*api.Branch, 0, len(branches)) + for i := range branches { + c, err := ctx.Repo.GitRepo.GetBranchCommit(branches[i].Name) + if err != nil { + // Skip if this branch doesn't exist anymore. + if git.IsErrNotExist(err) { + totalNumOfBranches-- + continue + } + ctx.Error(http.StatusInternalServerError, "GetCommit", err) + return + } + + branchProtection := rules.GetFirstMatched(branches[i].Name) + apiBranch, err := convert.ToBranch(ctx, ctx.Repo.Repository, branches[i].Name, c, branchProtection, ctx.Doer, ctx.Repo.IsAdmin()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToBranch", err) + return + } + apiBranches = append(apiBranches, apiBranch) + } + } + + ctx.SetLinkHeader(int(totalNumOfBranches), listOptions.PageSize) + ctx.SetTotalCountHeader(totalNumOfBranches) + ctx.JSON(http.StatusOK, apiBranches) +} + +// GetBranchProtection gets a branch protection +func GetBranchProtection(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/branch_protections/{name} repository repoGetBranchProtection + // --- + // summary: Get a specific branch protection for the repository + // 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: name + // in: path + // description: name of protected branch + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/BranchProtection" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + bpName := ctx.Params(":name") + bp, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err) + return + } + if bp == nil || bp.RepoID != repo.ID { + ctx.NotFound() + return + } + + ctx.JSON(http.StatusOK, convert.ToBranchProtection(ctx, bp, repo)) +} + +// ListBranchProtections list branch protections for a repo +func ListBranchProtections(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/branch_protections repository repoListBranchProtection + // --- + // summary: List branch protections for a repository + // 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 + // responses: + // "200": + // "$ref": "#/responses/BranchProtectionList" + + repo := ctx.Repo.Repository + bps, err := git_model.FindRepoProtectedBranchRules(ctx, repo.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedBranches", err) + return + } + apiBps := make([]*api.BranchProtection, len(bps)) + for i := range bps { + apiBps[i] = convert.ToBranchProtection(ctx, bps[i], repo) + } + + ctx.JSON(http.StatusOK, apiBps) +} + +// CreateBranchProtection creates a branch protection for a repo +func CreateBranchProtection(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/branch_protections repository repoCreateBranchProtection + // --- + // summary: Create a branch protections for a repository + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateBranchProtectionOption" + // responses: + // "201": + // "$ref": "#/responses/BranchProtection" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateBranchProtectionOption) + repo := ctx.Repo.Repository + + ruleName := form.RuleName + if ruleName == "" { + ruleName = form.BranchName //nolint + } + if len(ruleName) == 0 { + ctx.Error(http.StatusBadRequest, "both rule_name and branch_name are empty", "both rule_name and branch_name are empty") + return + } + + isPlainRule := !git_model.IsRuleNameSpecial(ruleName) + var isBranchExist bool + if isPlainRule { + isBranchExist = git.IsBranchExist(ctx.Req.Context(), ctx.Repo.Repository.RepoPath(), ruleName) + } + + protectBranch, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, ruleName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectBranchOfRepoByName", err) + return + } else if protectBranch != nil { + ctx.Error(http.StatusForbidden, "Create branch protection", "Branch protection already exist") + return + } + + var requiredApprovals int64 + if form.RequiredApprovals > 0 { + requiredApprovals = form.RequiredApprovals + } + + whitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + mergeWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + approvalsWhitelistUsers, err := user_model.GetUserIDsByNames(ctx, form.ApprovalsWhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 + if repo.Owner.IsOrganization() { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ApprovalsWhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } + + protectBranch = &git_model.ProtectedBranch{ + RepoID: ctx.Repo.Repository.ID, + RuleName: ruleName, + CanPush: form.EnablePush, + EnableWhitelist: form.EnablePush && form.EnablePushWhitelist, + EnableMergeWhitelist: form.EnableMergeWhitelist, + WhitelistDeployKeys: form.EnablePush && form.EnablePushWhitelist && form.PushWhitelistDeployKeys, + EnableStatusCheck: form.EnableStatusCheck, + StatusCheckContexts: form.StatusCheckContexts, + EnableApprovalsWhitelist: form.EnableApprovalsWhitelist, + RequiredApprovals: requiredApprovals, + BlockOnRejectedReviews: form.BlockOnRejectedReviews, + BlockOnOfficialReviewRequests: form.BlockOnOfficialReviewRequests, + DismissStaleApprovals: form.DismissStaleApprovals, + IgnoreStaleApprovals: form.IgnoreStaleApprovals, + RequireSignedCommits: form.RequireSignedCommits, + ProtectedFilePatterns: form.ProtectedFilePatterns, + UnprotectedFilePatterns: form.UnprotectedFilePatterns, + BlockOnOutdatedBranch: form.BlockOnOutdatedBranch, + ApplyToAdmins: form.ApplyToAdmins, + } + + err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ + UserIDs: whitelistUsers, + TeamIDs: whitelistTeams, + MergeUserIDs: mergeWhitelistUsers, + MergeTeamIDs: mergeWhitelistTeams, + ApprovalsUserIDs: approvalsWhitelistUsers, + ApprovalsTeamIDs: approvalsWhitelistTeams, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateProtectBranch", err) + return + } + + if isBranchExist { + if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, ruleName); err != nil { + ctx.Error(http.StatusInternalServerError, "CheckPRsForBaseBranch", err) + return + } + } else { + if !isPlainRule { + if ctx.Repo.GitRepo == nil { + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + return + } + defer func() { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + }() + } + // FIXME: since we only need to recheck files protected rules, we could improve this + matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, ruleName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindAllMatchedBranches", err) + return + } + + for _, branchName := range matchedBranches { + if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil { + ctx.Error(http.StatusInternalServerError, "CheckPRsForBaseBranch", err) + return + } + } + } + } + + // Reload from db to get all whitelists + bp, err := git_model.GetProtectedBranchRuleByName(ctx, ctx.Repo.Repository.ID, ruleName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err) + return + } + if bp == nil || bp.RepoID != ctx.Repo.Repository.ID { + ctx.Error(http.StatusInternalServerError, "New branch protection not found", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToBranchProtection(ctx, bp, repo)) +} + +// EditBranchProtection edits a branch protection for a repo +func EditBranchProtection(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/branch_protections/{name} repository repoEditBranchProtection + // --- + // summary: Edit a branch protections for a repository. Only fields that are set will be changed + // consumes: + // - application/json + // 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: name + // in: path + // description: name of protected branch + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditBranchProtectionOption" + // responses: + // "200": + // "$ref": "#/responses/BranchProtection" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.EditBranchProtectionOption) + repo := ctx.Repo.Repository + bpName := ctx.Params(":name") + protectBranch, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err) + return + } + if protectBranch == nil || protectBranch.RepoID != repo.ID { + ctx.NotFound() + return + } + + if form.EnablePush != nil { + if !*form.EnablePush { + protectBranch.CanPush = false + protectBranch.EnableWhitelist = false + protectBranch.WhitelistDeployKeys = false + } else { + protectBranch.CanPush = true + if form.EnablePushWhitelist != nil { + if !*form.EnablePushWhitelist { + protectBranch.EnableWhitelist = false + protectBranch.WhitelistDeployKeys = false + } else { + protectBranch.EnableWhitelist = true + if form.PushWhitelistDeployKeys != nil { + protectBranch.WhitelistDeployKeys = *form.PushWhitelistDeployKeys + } + } + } + } + } + + if form.EnableMergeWhitelist != nil { + protectBranch.EnableMergeWhitelist = *form.EnableMergeWhitelist + } + + if form.EnableStatusCheck != nil { + protectBranch.EnableStatusCheck = *form.EnableStatusCheck + } + + if form.StatusCheckContexts != nil { + protectBranch.StatusCheckContexts = form.StatusCheckContexts + } + + if form.RequiredApprovals != nil && *form.RequiredApprovals >= 0 { + protectBranch.RequiredApprovals = *form.RequiredApprovals + } + + if form.EnableApprovalsWhitelist != nil { + protectBranch.EnableApprovalsWhitelist = *form.EnableApprovalsWhitelist + } + + if form.BlockOnRejectedReviews != nil { + protectBranch.BlockOnRejectedReviews = *form.BlockOnRejectedReviews + } + + if form.BlockOnOfficialReviewRequests != nil { + protectBranch.BlockOnOfficialReviewRequests = *form.BlockOnOfficialReviewRequests + } + + if form.DismissStaleApprovals != nil { + protectBranch.DismissStaleApprovals = *form.DismissStaleApprovals + } + + if form.IgnoreStaleApprovals != nil { + protectBranch.IgnoreStaleApprovals = *form.IgnoreStaleApprovals + } + + if form.RequireSignedCommits != nil { + protectBranch.RequireSignedCommits = *form.RequireSignedCommits + } + + if form.ProtectedFilePatterns != nil { + protectBranch.ProtectedFilePatterns = *form.ProtectedFilePatterns + } + + if form.UnprotectedFilePatterns != nil { + protectBranch.UnprotectedFilePatterns = *form.UnprotectedFilePatterns + } + + if form.BlockOnOutdatedBranch != nil { + protectBranch.BlockOnOutdatedBranch = *form.BlockOnOutdatedBranch + } + + if form.ApplyToAdmins != nil { + protectBranch.ApplyToAdmins = *form.ApplyToAdmins + } + + var whitelistUsers []int64 + if form.PushWhitelistUsernames != nil { + whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.PushWhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + } else { + whitelistUsers = protectBranch.WhitelistUserIDs + } + var mergeWhitelistUsers []int64 + if form.MergeWhitelistUsernames != nil { + mergeWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.MergeWhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + } else { + mergeWhitelistUsers = protectBranch.MergeWhitelistUserIDs + } + var approvalsWhitelistUsers []int64 + if form.ApprovalsWhitelistUsernames != nil { + approvalsWhitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.ApprovalsWhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + } else { + approvalsWhitelistUsers = protectBranch.ApprovalsWhitelistUserIDs + } + + var whitelistTeams, mergeWhitelistTeams, approvalsWhitelistTeams []int64 + if repo.Owner.IsOrganization() { + if form.PushWhitelistTeams != nil { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.PushWhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } else { + whitelistTeams = protectBranch.WhitelistTeamIDs + } + if form.MergeWhitelistTeams != nil { + mergeWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.MergeWhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } else { + mergeWhitelistTeams = protectBranch.MergeWhitelistTeamIDs + } + if form.ApprovalsWhitelistTeams != nil { + approvalsWhitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.ApprovalsWhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } else { + approvalsWhitelistTeams = protectBranch.ApprovalsWhitelistTeamIDs + } + } + + err = git_model.UpdateProtectBranch(ctx, ctx.Repo.Repository, protectBranch, git_model.WhitelistOptions{ + UserIDs: whitelistUsers, + TeamIDs: whitelistTeams, + MergeUserIDs: mergeWhitelistUsers, + MergeTeamIDs: mergeWhitelistTeams, + ApprovalsUserIDs: approvalsWhitelistUsers, + ApprovalsTeamIDs: approvalsWhitelistTeams, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateProtectBranch", err) + return + } + + isPlainRule := !git_model.IsRuleNameSpecial(bpName) + var isBranchExist bool + if isPlainRule { + isBranchExist = git.IsBranchExist(ctx.Req.Context(), ctx.Repo.Repository.RepoPath(), bpName) + } + + if isBranchExist { + if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, bpName); err != nil { + ctx.Error(http.StatusInternalServerError, "CheckPrsForBaseBranch", err) + return + } + } else { + if !isPlainRule { + if ctx.Repo.GitRepo == nil { + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + return + } + defer func() { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + }() + } + + // FIXME: since we only need to recheck files protected rules, we could improve this + matchedBranches, err := git_model.FindAllMatchedBranches(ctx, ctx.Repo.Repository.ID, protectBranch.RuleName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindAllMatchedBranches", err) + return + } + + for _, branchName := range matchedBranches { + if err = pull_service.CheckPRsForBaseBranch(ctx, ctx.Repo.Repository, branchName); err != nil { + ctx.Error(http.StatusInternalServerError, "CheckPrsForBaseBranch", err) + return + } + } + } + } + + // Reload from db to ensure get all whitelists + bp, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedBranchBy", err) + return + } + if bp == nil || bp.RepoID != ctx.Repo.Repository.ID { + ctx.Error(http.StatusInternalServerError, "New branch protection not found", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToBranchProtection(ctx, bp, repo)) +} + +// DeleteBranchProtection deletes a branch protection for a repo +func DeleteBranchProtection(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/branch_protections/{name} repository repoDeleteBranchProtection + // --- + // summary: Delete a specific branch protection for the repository + // 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: name + // in: path + // description: name of protected branch + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + bpName := ctx.Params(":name") + bp, err := git_model.GetProtectedBranchRuleByName(ctx, repo.ID, bpName) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedBranchByID", err) + return + } + if bp == nil || bp.RepoID != repo.ID { + ctx.NotFound() + return + } + + if err := git_model.DeleteProtectedBranch(ctx, ctx.Repo.Repository, bp.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteProtectedBranch", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/collaborators.go b/routers/api/v1/repo/collaborators.go new file mode 100644 index 0000000..a43a21a --- /dev/null +++ b/routers/api/v1/repo/collaborators.go @@ -0,0 +1,370 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + repo_module "code.gitea.io/gitea/modules/repository" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + repo_service "code.gitea.io/gitea/services/repository" +) + +// ListCollaborators list a repository's collaborators +func ListCollaborators(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/collaborators repository repoListCollaborators + // --- + // summary: List a repository's collaborators + // 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: 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + count, err := db.Count[repo_model.Collaboration](ctx, repo_model.FindCollaborationOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + ctx.InternalServerError(err) + return + } + + collaborators, err := repo_model.GetCollaborators(ctx, ctx.Repo.Repository.ID, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListCollaborators", err) + return + } + + users := make([]*api.User, len(collaborators)) + for i, collaborator := range collaborators { + users[i] = convert.ToUser(ctx, collaborator.User, ctx.Doer) + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, users) +} + +// IsCollaborator check if a user is a collaborator of a repository +func IsCollaborator(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/collaborators/{collaborator} repository repoCheckCollaborator + // --- + // summary: Check if a user is a collaborator of a repository + // 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: collaborator + // in: path + // description: username of the collaborator + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + user, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + isColab, err := repo_model.IsCollaborator(ctx, ctx.Repo.Repository.ID, user.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsCollaborator", err) + return + } + if isColab { + ctx.Status(http.StatusNoContent) + } else { + ctx.NotFound() + } +} + +// AddCollaborator add a collaborator to a repository +func AddCollaborator(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/collaborators/{collaborator} repository repoAddCollaborator + // --- + // summary: Add a collaborator to a repository + // 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: collaborator + // in: path + // description: username of the collaborator to add + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/AddCollaboratorOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "403": + // "$ref": "#/responses/forbidden" + + form := web.GetForm(ctx).(*api.AddCollaboratorOption) + + collaborator, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + + if !collaborator.IsActive { + ctx.Error(http.StatusInternalServerError, "InactiveCollaborator", errors.New("collaborator's account is inactive")) + return + } + + if err := repo_module.AddCollaborator(ctx, ctx.Repo.Repository, collaborator); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "AddCollaborator", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddCollaborator", err) + } + return + } + + if form.Permission != nil { + if err := repo_model.ChangeCollaborationAccessMode(ctx, ctx.Repo.Repository, collaborator.ID, perm.ParseAccessMode(*form.Permission)); err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeCollaborationAccessMode", err) + return + } + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteCollaborator delete a collaborator from a repository +func DeleteCollaborator(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/collaborators/{collaborator} repository repoDeleteCollaborator + // --- + // summary: Delete a collaborator from a repository + // 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: collaborator + // in: path + // description: username of the collaborator to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + collaborator, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + + if err := repo_service.DeleteCollaboration(ctx, ctx.Repo.Repository, collaborator.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteCollaboration", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// GetRepoPermissions gets repository permissions for a user +func GetRepoPermissions(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/collaborators/{collaborator}/permission repository repoGetRepoPermissions + // --- + // summary: Get repository permissions for a user + // 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: collaborator + // in: path + // description: username of the collaborator + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/RepoCollaboratorPermission" + // "404": + // "$ref": "#/responses/notFound" + // "403": + // "$ref": "#/responses/forbidden" + + if !ctx.Doer.IsAdmin && ctx.Doer.LoginName != ctx.Params(":collaborator") && !ctx.IsUserRepoAdmin() { + ctx.Error(http.StatusForbidden, "User", "Only admins can query all permissions, repo admins can query all repo permissions, collaborators can query only their own") + return + } + + collaborator, err := user_model.GetUserByName(ctx, ctx.Params(":collaborator")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusNotFound, "GetUserByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + + permission, err := access_model.GetUserRepoPermission(ctx, ctx.Repo.Repository, collaborator) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToUserAndPermission(ctx, collaborator, ctx.ContextUser, permission.AccessMode)) +} + +// GetReviewers return all users that can be requested to review in this repo +func GetReviewers(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/reviewers repository repoGetReviewers + // --- + // summary: Return all users that can be requested to review in this repo + // 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 + // responses: + // "200": + // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" + + reviewers, err := repo_model.GetReviewers(ctx, ctx.Repo.Repository, ctx.Doer.ID, 0) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListCollaborators", err) + return + } + ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, reviewers)) +} + +// GetAssignees return all users that have write access and can be assigned to issues +func GetAssignees(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/assignees repository repoGetAssignees + // --- + // summary: Return all users that have write access and can be assigned to issues + // 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 + // responses: + // "200": + // "$ref": "#/responses/UserList" + // "404": + // "$ref": "#/responses/notFound" + + assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListCollaborators", err) + return + } + ctx.JSON(http.StatusOK, convert.ToUsers(ctx, ctx.Doer, assignees)) +} diff --git a/routers/api/v1/repo/commits.go b/routers/api/v1/repo/commits.go new file mode 100644 index 0000000..c5e8cf9 --- /dev/null +++ b/routers/api/v1/repo/commits.go @@ -0,0 +1,376 @@ +// Copyright 2018 The Gogs Authors. All rights reserved. +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "math" + "net/http" + "strconv" + + issues_model "code.gitea.io/gitea/models/issues" + 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/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetSingleCommit get a commit via sha +func GetSingleCommit(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/commits/{sha} repository repoGetSingleCommit + // --- + // summary: Get a single commit from a repository + // 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: sha + // in: path + // description: a git ref or commit sha + // type: string + // required: true + // - name: stat + // in: query + // description: include diff stats for every commit (disable for speedup, default 'true') + // type: boolean + // - name: verification + // in: query + // description: include verification for every commit (disable for speedup, default 'true') + // type: boolean + // - name: files + // in: query + // description: include a list of affected files for every commit (disable for speedup, default 'true') + // type: boolean + // responses: + // "200": + // "$ref": "#/responses/Commit" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + sha := ctx.Params(":sha") + if !git.IsValidRefPattern(sha) { + ctx.Error(http.StatusUnprocessableEntity, "no valid ref or sha", fmt.Sprintf("no valid ref or sha: %s", sha)) + return + } + + getCommit(ctx, sha, convert.ParseCommitOptions(ctx)) +} + +func getCommit(ctx *context.APIContext, identifier string, toCommitOpts convert.ToCommitOptions) { + commit, err := ctx.Repo.GitRepo.GetCommit(identifier) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(identifier) + return + } + ctx.Error(http.StatusInternalServerError, "gitRepo.GetCommit", err) + return + } + + json, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, commit, nil, toCommitOpts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "toCommit", err) + return + } + ctx.JSON(http.StatusOK, json) +} + +// GetAllCommits get all commits via +func GetAllCommits(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/commits repository repoGetAllCommits + // --- + // summary: Get a list of all commits from a repository + // 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: sha + // in: query + // description: SHA or branch to start listing commits from (usually 'master') + // type: string + // - name: path + // in: query + // description: filepath of a file/dir + // type: string + // - name: stat + // in: query + // description: include diff stats for every commit (disable for speedup, default 'true') + // type: boolean + // - name: verification + // in: query + // description: include verification for every commit (disable for speedup, default 'true') + // type: boolean + // - name: files + // in: query + // description: include a list of affected files for every commit (disable for speedup, default 'true') + // type: boolean + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results (ignored if used with 'path') + // type: integer + // - name: not + // in: query + // description: commits that match the given specifier will not be listed. + // type: string + // responses: + // "200": + // "$ref": "#/responses/CommitList" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/EmptyRepository" + + if ctx.Repo.Repository.IsEmpty { + ctx.JSON(http.StatusConflict, api.APIError{ + Message: "Git Repository is empty.", + URL: setting.API.SwaggerURL, + }) + return + } + + listOptions := utils.GetListOptions(ctx) + if listOptions.Page <= 0 { + listOptions.Page = 1 + } + + if listOptions.PageSize > setting.Git.CommitsRangeSize { + listOptions.PageSize = setting.Git.CommitsRangeSize + } + + sha := ctx.FormString("sha") + path := ctx.FormString("path") + not := ctx.FormString("not") + + var ( + commitsCountTotal int64 + commits []*git.Commit + err error + ) + + if len(path) == 0 { + var baseCommit *git.Commit + if len(sha) == 0 { + // no sha supplied - use default branch + head, err := ctx.Repo.GitRepo.GetHEADBranch() + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetHEADBranch", err) + return + } + + baseCommit, err = ctx.Repo.GitRepo.GetBranchCommit(head.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommit", err) + return + } + } else { + // get commit specified by sha + baseCommit, err = ctx.Repo.GitRepo.GetCommit(sha) + if err != nil { + ctx.NotFoundOrServerError("GetCommit", git.IsErrNotExist, err) + return + } + } + + // Total commit count + commitsCountTotal, err = git.CommitsCount(ctx.Repo.GitRepo.Ctx, git.CommitsCountOptions{ + RepoPath: ctx.Repo.GitRepo.Path, + Not: not, + Revision: []string{baseCommit.ID.String()}, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommitsCount", err) + return + } + + // Query commits + commits, err = baseCommit.CommitsByRange(listOptions.Page, listOptions.PageSize, not) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CommitsByRange", err) + return + } + } else { + if len(sha) == 0 { + sha = ctx.Repo.Repository.DefaultBranch + } + + commitsCountTotal, err = git.CommitsCount(ctx, + git.CommitsCountOptions{ + RepoPath: ctx.Repo.GitRepo.Path, + Not: not, + Revision: []string{sha}, + RelPath: []string{path}, + }) + + if err != nil { + ctx.Error(http.StatusInternalServerError, "FileCommitsCount", err) + return + } else if commitsCountTotal == 0 { + ctx.NotFound("FileCommitsCount", nil) + return + } + + commits, err = ctx.Repo.GitRepo.CommitsByFileAndRange( + git.CommitsByFileAndRangeOptions{ + Revision: sha, + File: path, + Not: not, + Page: listOptions.Page, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CommitsByFileAndRange", err) + return + } + } + + pageCount := int(math.Ceil(float64(commitsCountTotal) / float64(listOptions.PageSize))) + userCache := make(map[string]*user_model.User) + apiCommits := make([]*api.Commit, len(commits)) + + for i, commit := range commits { + // Create json struct + apiCommits[i], err = convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, commit, userCache, convert.ParseCommitOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "toCommit", err) + return + } + } + + ctx.SetLinkHeader(int(commitsCountTotal), listOptions.PageSize) + ctx.SetTotalCountHeader(commitsCountTotal) + + // kept for backwards compatibility + ctx.RespHeader().Set("X-Page", strconv.Itoa(listOptions.Page)) + ctx.RespHeader().Set("X-PerPage", strconv.Itoa(listOptions.PageSize)) + ctx.RespHeader().Set("X-Total", strconv.FormatInt(commitsCountTotal, 10)) + ctx.RespHeader().Set("X-PageCount", strconv.Itoa(pageCount)) + ctx.RespHeader().Set("X-HasMore", strconv.FormatBool(listOptions.Page < pageCount)) + ctx.AppendAccessControlExposeHeaders("X-Page", "X-PerPage", "X-Total", "X-PageCount", "X-HasMore") + + ctx.JSON(http.StatusOK, &apiCommits) +} + +// DownloadCommitDiffOrPatch render a commit's raw diff or patch +func DownloadCommitDiffOrPatch(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/commits/{sha}.{diffType} repository repoDownloadCommitDiffOrPatch + // --- + // summary: Get a commit's diff or patch + // 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 + // - name: sha + // in: path + // description: SHA of the commit to get + // type: string + // required: true + // - name: diffType + // in: path + // description: whether the output is diff or patch + // type: string + // enum: [diff, patch] + // required: true + // responses: + // "200": + // "$ref": "#/responses/string" + // "404": + // "$ref": "#/responses/notFound" + sha := ctx.Params(":sha") + diffType := git.RawDiffType(ctx.Params(":diffType")) + + if err := git.GetRawDiff(ctx.Repo.GitRepo, sha, diffType, ctx.Resp); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(sha) + return + } + ctx.Error(http.StatusInternalServerError, "DownloadCommitDiffOrPatch", err) + return + } +} + +// GetCommitPullRequest returns the pull request of the commit +func GetCommitPullRequest(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/commits/{sha}/pull repository repoGetCommitPullRequest + // --- + // summary: Get the pull request of the commit + // 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: sha + // in: path + // description: SHA of the commit to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullRequest" + // "404": + // "$ref": "#/responses/notFound" + + pr, err := issues_model.GetPullRequestByMergedCommit(ctx, ctx.Repo.Repository.ID, ctx.Params("ref")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.Error(http.StatusNotFound, "GetPullRequestByMergedCommit", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) + return + } + if err = pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) +} diff --git a/routers/api/v1/repo/compare.go b/routers/api/v1/repo/compare.go new file mode 100644 index 0000000..429145c --- /dev/null +++ b/routers/api/v1/repo/compare.go @@ -0,0 +1,99 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// CompareDiff compare two branches or commits +func CompareDiff(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/compare/{basehead} repository repoCompareDiff + // --- + // summary: Get commit comparison information + // 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: basehead + // in: path + // description: compare two branches or commits + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Compare" + // "404": + // "$ref": "#/responses/notFound" + + if ctx.Repo.GitRepo == nil { + gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + return + } + ctx.Repo.GitRepo = gitRepo + defer gitRepo.Close() + } + + infoPath := ctx.Params("*") + infos := []string{ctx.Repo.Repository.DefaultBranch, ctx.Repo.Repository.DefaultBranch} + if infoPath != "" { + infos = strings.SplitN(infoPath, "...", 2) + if len(infos) != 2 { + if infos = strings.SplitN(infoPath, "..", 2); len(infos) != 2 { + infos = []string{ctx.Repo.Repository.DefaultBranch, infoPath} + } + } + } + + _, headGitRepo, ci, _, _ := parseCompareInfo(ctx, api.CreatePullRequestOption{ + Base: infos[0], + Head: infos[1], + }) + if ctx.Written() { + return + } + defer headGitRepo.Close() + + verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") + files := ctx.FormString("files") == "" || ctx.FormBool("files") + + apiCommits := make([]*api.Commit, 0, len(ci.Commits)) + userCache := make(map[string]*user_model.User) + for i := 0; i < len(ci.Commits); i++ { + apiCommit, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, ci.Commits[i], userCache, + convert.ToCommitOptions{ + Stat: true, + Verification: verification, + Files: files, + }) + if err != nil { + ctx.ServerError("toCommit", err) + return + } + apiCommits = append(apiCommits, apiCommit) + } + + ctx.JSON(http.StatusOK, &api.Compare{ + TotalCommits: len(ci.Commits), + Commits: apiCommits, + }) +} diff --git a/routers/api/v1/repo/file.go b/routers/api/v1/repo/file.go new file mode 100644 index 0000000..1fa44d5 --- /dev/null +++ b/routers/api/v1/repo/file.go @@ -0,0 +1,1014 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "path" + "strings" + "time" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/httpcache" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/common" + "code.gitea.io/gitea/services/context" + archiver_service "code.gitea.io/gitea/services/repository/archiver" + files_service "code.gitea.io/gitea/services/repository/files" +) + +const ( + giteaObjectTypeHeader = "X-Gitea-Object-Type" + forgejoObjectTypeHeader = "X-Forgejo-Object-Type" +) + +// GetRawFile get a file by path on a repository +func GetRawFile(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/raw/{filepath} repository repoGetRawFile + // --- + // summary: Get a file from a repository + // produces: + // - application/octet-stream + // 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: filepath of the file to get + // type: string + // required: true + // - name: ref + // in: query + // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)" + // type: string + // required: false + // responses: + // 200: + // description: Returns raw file content. + // schema: + // type: file + // "404": + // "$ref": "#/responses/notFound" + + if ctx.Repo.Repository.IsEmpty { + ctx.NotFound() + return + } + + blob, entry, lastModified := getBlobForEntry(ctx) + if ctx.Written() { + return + } + + ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry))) + ctx.RespHeader().Set(forgejoObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry))) + + if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { + ctx.Error(http.StatusInternalServerError, "ServeBlob", err) + } +} + +// GetRawFileOrLFS get a file by repo's path, redirecting to LFS if necessary. +func GetRawFileOrLFS(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/media/{filepath} repository repoGetRawFileOrLFS + // --- + // summary: Get a file or it's LFS object from a repository + // produces: + // - application/octet-stream + // 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: filepath of the file to get + // type: string + // required: true + // - name: ref + // in: query + // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)" + // type: string + // required: false + // responses: + // 200: + // description: Returns raw file content. + // schema: + // type: file + // "404": + // "$ref": "#/responses/notFound" + + if ctx.Repo.Repository.IsEmpty { + ctx.NotFound() + return + } + + blob, entry, lastModified := getBlobForEntry(ctx) + if ctx.Written() { + return + } + + ctx.RespHeader().Set(giteaObjectTypeHeader, string(files_service.GetObjectTypeFromTreeEntry(entry))) + ctx.RespHeader().Set(forgejoObjectTypeHeader, 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 { + // First handle caching for the blob + if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { + return + } + + // OK not cached - serve! + if err := common.ServeBlob(ctx.Base, ctx.Repo.TreePath, blob, lastModified); err != nil { + ctx.ServerError("ServeBlob", 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) + dataRc, err := blob.DataAsync() + if err != nil { + ctx.ServerError("DataAsync", err) + return + } + + // FIXME: code from #19689, what if the file is large ... OOM ... + buf, err := io.ReadAll(dataRc) + if err != nil { + _ = dataRc.Close() + ctx.ServerError("DataAsync", err) + return + } + + if err := dataRc.Close(); err != nil { + log.Error("Error whilst closing blob %s reader in %-v. Error: %v", blob.ID, ctx.Repo.Repository, err) + } + + // Check if the blob represents a pointer + pointer, _ := lfs.ReadPointer(bytes.NewReader(buf)) + + // if it's not a pointer, just serve the data directly + if !pointer.IsValid() { + // First handle caching for the blob + if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { + return + } + + // OK not cached - serve! + common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) + return + } + + // Now check if there is a MetaObject for this pointer + meta, err := git_model.GetLFSMetaObjectByOid(ctx, ctx.Repo.Repository.ID, pointer.Oid) + + // If there isn't one, just serve the data directly + if err == git_model.ErrLFSObjectNotExist { + // Handle caching for the blob SHA (not the LFS object OID) + if httpcache.HandleGenericETagTimeCache(ctx.Req, ctx.Resp, `"`+blob.ID.String()+`"`, lastModified) { + return + } + + common.ServeContentByReader(ctx.Base, ctx.Repo.TreePath, blob.Size(), bytes.NewReader(buf)) + return + } else if err != nil { + ctx.ServerError("GetLFSMetaObjectByOid", err) + return + } + + // Handle caching for the LFS object OID + if httpcache.HandleGenericETagCache(ctx.Req, ctx.Resp, `"`+pointer.Oid+`"`) { + return + } + + if setting.LFS.Storage.MinioConfig.ServeDirect { + // If we have a signed url (S3, object storage), redirect to this directly. + u, err := storage.LFS.URL(pointer.RelativePath(), blob.Name()) + if u != nil && err == nil { + ctx.Redirect(u.String()) + return + } + } + + lfsDataRc, err := lfs.ReadMetaObject(meta.Pointer) + if err != nil { + ctx.ServerError("ReadMetaObject", err) + return + } + defer lfsDataRc.Close() + + common.ServeContentByReadSeeker(ctx.Base, ctx.Repo.TreePath, lastModified, lfsDataRc) +} + +func getBlobForEntry(ctx *context.APIContext) (blob *git.Blob, entry *git.TreeEntry, lastModified *time.Time) { + entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetTreeEntryByPath", err) + } + return nil, nil, nil + } + + if entry.IsDir() || entry.IsSubModule() { + ctx.NotFound("getBlobForEntry", nil) + return nil, nil, nil + } + + info, _, err := git.Entries([]*git.TreeEntry{entry}).GetCommitsInfo(ctx, ctx.Repo.Commit, path.Dir("/" + ctx.Repo.TreePath)[1:]) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommitsInfo", err) + return nil, nil, nil + } + + if len(info) == 1 { + // Not Modified + lastModified = &info[0].Commit.Committer.When + } + blob = entry.Blob() + + return blob, entry, lastModified +} + +// GetArchive get archive of a repository +func GetArchive(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/archive/{archive} repository repoGetArchive + // --- + // summary: Get an archive of a repository + // produces: + // - application/octet-stream + // - application/zip + // - application/gzip + // 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: archive + // in: path + // description: the git reference for download with attached archive format (e.g. master.zip) + // type: string + // required: true + // responses: + // 200: + // description: success + // "404": + // "$ref": "#/responses/notFound" + + if ctx.Repo.GitRepo == nil { + gitRepo, err := gitrepo.OpenRepository(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + return + } + ctx.Repo.GitRepo = gitRepo + defer gitRepo.Close() + } + + archiveDownload(ctx) +} + +func archiveDownload(ctx *context.APIContext) { + uri := ctx.Params("*") + aReq, err := archiver_service.NewRequest(ctx, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, uri) + if err != nil { + if errors.Is(err, archiver_service.ErrUnknownArchiveFormat{}) { + ctx.Error(http.StatusBadRequest, "unknown archive format", err) + } else if errors.Is(err, archiver_service.RepoRefNotFoundError{}) { + ctx.Error(http.StatusNotFound, "unrecognized reference", err) + } else { + ctx.ServerError("archiver_service.NewRequest", err) + } + return + } + + archiver, err := aReq.Await(ctx) + if err != nil { + ctx.ServerError("archiver.Await", err) + return + } + + download(ctx, aReq.GetArchiveName(), archiver) +} + +func download(ctx *context.APIContext, archiveName string, archiver *repo_model.RepoArchiver) { + downloadName := ctx.Repo.Repository.Name + "-" + archiveName + + // Add nix format link header so tarballs lock correctly: + // https://github.com/nixos/nix/blob/56763ff918eb308db23080e560ed2ea3e00c80a7/doc/manual/src/protocols/tarball-fetcher.md + ctx.Resp.Header().Add("Link", fmt.Sprintf("<%s/archive/%s.tar.gz?rev=%s>; rel=\"immutable\"", + ctx.Repo.Repository.APIURL(), + archiver.CommitID, archiver.CommitID)) + + rPath := archiver.RelativePath() + if setting.RepoArchive.Storage.MinioConfig.ServeDirect { + // If we have a signed url (S3, object storage), redirect to this directly. + u, err := storage.RepoArchives.URL(rPath, downloadName) + if u != nil && err == nil { + ctx.Redirect(u.String()) + return + } + } + + // If we have matched and access to release or issue + fr, err := storage.RepoArchives.Open(rPath) + if err != nil { + ctx.ServerError("Open", err) + return + } + defer fr.Close() + + contentType := "" + switch archiver.Type { + case git.ZIP: + contentType = "application/zip" + case git.TARGZ: + // Per RFC6713. + contentType = "application/gzip" + } + + ctx.ServeContent(fr, &context.ServeHeaderOptions{ + ContentType: contentType, + Filename: downloadName, + LastModified: archiver.CreatedUnix.AsLocalTime(), + }) +} + +// GetEditorconfig get editor config of a repository +func GetEditorconfig(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/editorconfig/{filepath} repository repoGetEditorConfig + // --- + // summary: Get the EditorConfig definitions of a file in a repository + // 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: filepath of file to get + // type: string + // required: true + // - name: ref + // in: query + // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)" + // type: string + // required: false + // responses: + // 200: + // description: success + // "404": + // "$ref": "#/responses/notFound" + + ec, _, err := ctx.Repo.GetEditorconfig(ctx.Repo.Commit) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetEditorconfig", err) + } + return + } + + fileName := ctx.Params("filename") + def, err := ec.GetDefinitionForFilename(fileName) + if def == nil { + ctx.NotFound(err) + return + } + 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 +} + +// canReadFiles returns true if repository is readable and user has proper access level. +func canReadFiles(r *context.Repository) bool { + return r.Permission.CanRead(unit.TypeCode) +} + +func base64Reader(s string) (io.ReadSeeker, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, err + } + return bytes.NewReader(b), nil +} + +// ChangeFiles handles API call for modifying multiple files +func ChangeFiles(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/contents repository repoChangeFiles + // --- + // summary: Modify multiple files in a repository + // consumes: + // - application/json + // 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: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/ChangeFilesOptions" + // responses: + // "201": + // "$ref": "#/responses/FilesResponse" + // "403": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" + + apiOpts := web.GetForm(ctx).(*api.ChangeFilesOptions) + + if apiOpts.BranchName == "" { + apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch + } + + var files []*files_service.ChangeRepoFile + for _, file := range apiOpts.Files { + contentReader, err := base64Reader(file.ContentBase64) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "Invalid base64 content", err) + return + } + changeRepoFile := &files_service.ChangeRepoFile{ + Operation: file.Operation, + TreePath: file.Path, + FromTreePath: file.FromPath, + 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{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &files_service.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: 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() + } + + if opts.Message == "" { + opts.Message = changeFilesCommitMessage(ctx, files) + } + + if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil { + handleCreateOrUpdateFileError(ctx, err) + } else { + ctx.JSON(http.StatusCreated, filesResponse) + } +} + +// CreateFile handles API call for creating a file +func CreateFile(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/contents/{filepath} repository repoCreateFile + // --- + // summary: Create a file in a repository + // consumes: + // - application/json + // 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 file to create + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateFileOptions" + // responses: + // "201": + // "$ref": "#/responses/FileResponse" + // "403": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" + + apiOpts := web.GetForm(ctx).(*api.CreateFileOptions) + + if apiOpts.BranchName == "" { + apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch + } + + contentReader, err := base64Reader(apiOpts.ContentBase64) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "Invalid base64 content", err) + return + } + + opts := &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "create", + TreePath: ctx.Params("*"), + ContentReader: contentReader, + }, + }, + Message: apiOpts.Message, + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + Committer: &files_service.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &files_service.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: 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() + } + + if opts.Message == "" { + opts.Message = changeFilesCommitMessage(ctx, opts.Files) + } + + if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil { + handleCreateOrUpdateFileError(ctx, err) + } else { + fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) + ctx.JSON(http.StatusCreated, fileResponse) + } +} + +// UpdateFile handles API call for updating a file +func UpdateFile(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/contents/{filepath} repository repoUpdateFile + // --- + // summary: Update a file in a repository + // consumes: + // - application/json + // 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 file to update + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/UpdateFileOptions" + // responses: + // "200": + // "$ref": "#/responses/FileResponse" + // "403": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" + apiOpts := web.GetForm(ctx).(*api.UpdateFileOptions) + if ctx.Repo.Repository.IsEmpty { + ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty")) + return + } + + if apiOpts.BranchName == "" { + apiOpts.BranchName = ctx.Repo.Repository.DefaultBranch + } + + contentReader, err := base64Reader(apiOpts.ContentBase64) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "Invalid base64 content", err) + return + } + + opts := &files_service.ChangeRepoFilesOptions{ + Files: []*files_service.ChangeRepoFile{ + { + Operation: "update", + ContentReader: contentReader, + SHA: apiOpts.SHA, + FromTreePath: apiOpts.FromPath, + TreePath: ctx.Params("*"), + }, + }, + Message: apiOpts.Message, + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + Committer: &files_service.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &files_service.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: 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() + } + + if opts.Message == "" { + opts.Message = changeFilesCommitMessage(ctx, opts.Files) + } + + if filesResponse, err := createOrUpdateFiles(ctx, opts); err != nil { + handleCreateOrUpdateFileError(ctx, err) + } else { + fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) + ctx.JSON(http.StatusOK, fileResponse) + } +} + +func handleCreateOrUpdateFileError(ctx *context.APIContext, err error) { + if models.IsErrUserCannotCommit(err) || models.IsErrFilePathProtected(err) { + ctx.Error(http.StatusForbidden, "Access", err) + return + } + if git_model.IsErrBranchAlreadyExists(err) || models.IsErrFilenameInvalid(err) || models.IsErrSHADoesNotMatch(err) || + models.IsErrFilePathInvalid(err) || models.IsErrRepoFileAlreadyExists(err) { + ctx.Error(http.StatusUnprocessableEntity, "Invalid", err) + return + } + if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) { + ctx.Error(http.StatusNotFound, "BranchDoesNotExist", err) + return + } + + ctx.Error(http.StatusInternalServerError, "UpdateFile", 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, + } + } + + return files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, opts) +} + +// format commit message if empty +func changeFilesCommitMessage(ctx *context.APIContext, files []*files_service.ChangeRepoFile) string { + var ( + createFiles []string + updateFiles []string + deleteFiles []string + ) + for _, file := range files { + switch file.Operation { + case "create": + createFiles = append(createFiles, file.TreePath) + case "update": + updateFiles = append(updateFiles, file.TreePath) + case "delete": + deleteFiles = append(deleteFiles, file.TreePath) + } + } + message := "" + if len(createFiles) != 0 { + message += ctx.Locale.TrString("repo.editor.add", strings.Join(createFiles, ", ")+"\n") + } + if len(updateFiles) != 0 { + message += ctx.Locale.TrString("repo.editor.update", strings.Join(updateFiles, ", ")+"\n") + } + if len(deleteFiles) != 0 { + message += ctx.Locale.TrString("repo.editor.delete", strings.Join(deleteFiles, ", ")) + } + return strings.Trim(message, "\n") +} + +// DeleteFile Delete a file in a repository +func DeleteFile(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/contents/{filepath} repository repoDeleteFile + // --- + // summary: Delete a file in a repository + // consumes: + // - application/json + // 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 file to delete + // type: string + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/DeleteFileOptions" + // responses: + // "200": + // "$ref": "#/responses/FileDeleteResponse" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + + apiOpts := web.GetForm(ctx).(*api.DeleteFileOptions) + if !canWriteFiles(ctx, apiOpts.BranchName) { + ctx.Error(http.StatusForbidden, "DeleteFile", repo_model.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.Doer.ID, + RepoName: ctx.Repo.Repository.LowerName, + }) + 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.Params("*"), + }, + }, + Message: apiOpts.Message, + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + Committer: &files_service.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &files_service.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: 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() + } + + 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) || models.IsErrRepoFileDoesNotExist(err) || git.IsErrNotExist(err) { + ctx.Error(http.StatusNotFound, "DeleteFile", err) + return + } else if git_model.IsErrBranchAlreadyExists(err) || + models.IsErrFilenameInvalid(err) || + models.IsErrSHADoesNotMatch(err) || + models.IsErrCommitIDDoesNotMatch(err) || + models.IsErrSHAOrCommitIDNotProvided(err) { + ctx.Error(http.StatusBadRequest, "DeleteFile", err) + return + } else if models.IsErrUserCannotCommit(err) { + ctx.Error(http.StatusForbidden, "DeleteFile", err) + return + } + ctx.Error(http.StatusInternalServerError, "DeleteFile", err) + } else { + fileResponse := files_service.GetFileResponseFromFilesResponse(filesResponse, 0) + ctx.JSON(http.StatusOK, fileResponse) // FIXME on APIv2: return http.StatusNoContent + } +} + +// GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir +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 + // 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 the repository’s default branch (usually master)" + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ContentsResponse" + // "404": + // "$ref": "#/responses/notFound" + + if !canReadFiles(ctx.Repo) { + ctx.Error(http.StatusInternalServerError, "GetContentsOrList", repo_model.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.Doer.ID, + RepoName: ctx.Repo.Repository.LowerName, + }) + return + } + + treePath := ctx.Params("*") + ref := ctx.FormTrim("ref") + + if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, treePath, ref); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetContentsOrList", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetContentsOrList", err) + } else { + ctx.JSON(http.StatusOK, fileList) + } +} + +// 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 + // 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: ref + // in: query + // description: "The name of the commit/branch/tag. Default the repository’s default branch (usually master)" + // type: string + // required: false + // responses: + // "200": + // "$ref": "#/responses/ContentsListResponse" + // "404": + // "$ref": "#/responses/notFound" + + // same as GetContents(), this function is here because swagger fails if path is empty in GetContents() interface + GetContents(ctx) +} diff --git a/routers/api/v1/repo/flags.go b/routers/api/v1/repo/flags.go new file mode 100644 index 0000000..ac5cb2e --- /dev/null +++ b/routers/api/v1/repo/flags.go @@ -0,0 +1,245 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" +) + +func ListFlags(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/flags repository repoListFlags + // --- + // summary: List a repository's flags + // 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 + // responses: + // "200": + // "$ref": "#/responses/StringSlice" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + repoFlags, err := ctx.Repo.Repository.ListFlags(ctx) + if err != nil { + ctx.InternalServerError(err) + return + } + + flags := make([]string, len(repoFlags)) + for i := range repoFlags { + flags[i] = repoFlags[i].Name + } + + ctx.SetTotalCountHeader(int64(len(repoFlags))) + ctx.JSON(http.StatusOK, flags) +} + +func ReplaceAllFlags(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/flags repository repoReplaceAllFlags + // --- + // summary: Replace all flags of a repository + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/ReplaceFlagsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + flagsForm := web.GetForm(ctx).(*api.ReplaceFlagsOption) + + if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, flagsForm.Flags); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func DeleteAllFlags(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/flags repository repoDeleteAllFlags + // --- + // summary: Remove all flags from a repository + // 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 + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, nil); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func HasFlag(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/flags/{flag} repository repoCheckFlag + // --- + // summary: Check if a repository has a given flag + // 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: flag + // in: path + // description: name of the flag + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + hasFlag := ctx.Repo.Repository.HasFlag(ctx, ctx.Params(":flag")) + if hasFlag { + ctx.Status(http.StatusNoContent) + } else { + ctx.NotFound() + } +} + +func AddFlag(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/flags/{flag} repository repoAddFlag + // --- + // summary: Add a flag to a repository + // 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: flag + // in: path + // description: name of the flag + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + flag := ctx.Params(":flag") + + if ctx.Repo.Repository.HasFlag(ctx, flag) { + ctx.Status(http.StatusNoContent) + return + } + + if err := ctx.Repo.Repository.AddFlag(ctx, flag); err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusNoContent) +} + +func DeleteFlag(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/flags/{flag} repository repoDeleteFlag + // --- + // summary: Remove a flag from a repository + // 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: flag + // in: path + // description: name of the flag + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + flag := ctx.Params(":flag") + + if _, err := ctx.Repo.Repository.DeleteFlag(ctx, flag); err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/fork.go b/routers/api/v1/repo/fork.go new file mode 100644 index 0000000..97aaffd --- /dev/null +++ b/routers/api/v1/repo/fork.go @@ -0,0 +1,167 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + 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/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + repo_service "code.gitea.io/gitea/services/repository" +) + +// ListForks list a repository's forks +func ListForks(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/forks repository listForks + // --- + // summary: List a repository's forks + // 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: 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/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" + + forks, err := repo_model.GetForks(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetForks", err) + return + } + apiForks := make([]*api.Repository, len(forks)) + for i, fork := range forks { + permission, err := access_model.GetUserRepoPermission(ctx, fork, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + apiForks[i] = convert.ToRepo(ctx, fork, permission) + } + + ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumForks)) + ctx.JSON(http.StatusOK, apiForks) +} + +// CreateFork create a fork of a repo +func CreateFork(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/forks repository createFork + // --- + // summary: Fork a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to fork + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to fork + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateForkOption" + // responses: + // "202": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateForkOption) + repo := ctx.Repo.Repository + var forker *user_model.User // user/org that will own the fork + if form.Organization == nil { + forker = ctx.Doer + } else { + org, err := organization.GetOrgByName(ctx, *form.Organization) + if err != nil { + if organization.IsErrOrgNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) + } + return + } + isMember, err := org.IsOrgMember(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOrgMember", err) + return + } else if !isMember { + ctx.Error(http.StatusForbidden, "isMemberNot", fmt.Sprintf("User is no Member of Organisation '%s'", org.Name)) + return + } + forker = org.AsUser() + } + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, forker.ID, forker.Name) { + return + } + + var name string + if form.Name == nil { + name = repo.Name + } else { + name = *form.Name + } + + fork, err := repo_service.ForkRepositoryAndUpdates(ctx, ctx.Doer, forker, repo_service.ForkRepoOptions{ + BaseRepo: repo, + Name: name, + Description: repo.Description, + }) + if err != nil { + if errors.Is(err, util.ErrAlreadyExist) || repo_model.IsErrReachLimitOfRepo(err) { + ctx.Error(http.StatusConflict, "ForkRepositoryAndUpdates", err) + } else { + ctx.Error(http.StatusInternalServerError, "ForkRepositoryAndUpdates", err) + } + return + } + + // TODO change back to 201 + ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx, fork, access_model.Permission{AccessMode: perm.AccessModeOwner})) +} diff --git a/routers/api/v1/repo/git_hook.go b/routers/api/v1/repo/git_hook.go new file mode 100644 index 0000000..26ae84d --- /dev/null +++ b/routers/api/v1/repo/git_hook.go @@ -0,0 +1,196 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListGitHooks list all Git hooks of a repository +func ListGitHooks(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/hooks/git repository repoListGitHooks + // --- + // summary: List the Git hooks in a repository + // 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 + // responses: + // "200": + // "$ref": "#/responses/GitHookList" + // "404": + // "$ref": "#/responses/notFound" + + hooks, err := ctx.Repo.GitRepo.Hooks() + if err != nil { + ctx.Error(http.StatusInternalServerError, "Hooks", err) + return + } + + apiHooks := make([]*api.GitHook, len(hooks)) + for i := range hooks { + apiHooks[i] = convert.ToGitHook(hooks[i]) + } + ctx.JSON(http.StatusOK, &apiHooks) +} + +// GetGitHook get a repo's Git hook by id +func GetGitHook(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/hooks/git/{id} repository repoGetGitHook + // --- + // summary: Get a Git hook + // 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: id + // in: path + // description: id of the hook to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/GitHook" + // "404": + // "$ref": "#/responses/notFound" + + hookID := ctx.Params(":id") + hook, err := ctx.Repo.GitRepo.GetHook(hookID) + if err != nil { + if err == git.ErrNotValidHook { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetHook", err) + } + return + } + ctx.JSON(http.StatusOK, convert.ToGitHook(hook)) +} + +// EditGitHook modify a Git hook of a repository +func EditGitHook(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/hooks/git/{id} repository repoEditGitHook + // --- + // summary: Edit a Git hook in a repository + // 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: id + // in: path + // description: id of the hook to get + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditGitHookOption" + // responses: + // "200": + // "$ref": "#/responses/GitHook" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditGitHookOption) + hookID := ctx.Params(":id") + hook, err := ctx.Repo.GitRepo.GetHook(hookID) + if err != nil { + if err == git.ErrNotValidHook { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetHook", err) + } + return + } + + hook.Content = form.Content + if err = hook.Update(); err != nil { + ctx.Error(http.StatusInternalServerError, "hook.Update", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToGitHook(hook)) +} + +// DeleteGitHook delete a Git hook of a repository +func DeleteGitHook(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/hooks/git/{id} repository repoDeleteGitHook + // --- + // summary: Delete a Git hook in a repository + // 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: id + // in: path + // description: id of the hook to get + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + hookID := ctx.Params(":id") + hook, err := ctx.Repo.GitRepo.GetHook(hookID) + if err != nil { + if err == git.ErrNotValidHook { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetHook", err) + } + return + } + + hook.Content = "" + if err = hook.Update(); err != nil { + ctx.Error(http.StatusInternalServerError, "hook.Update", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/git_ref.go b/routers/api/v1/repo/git_ref.go new file mode 100644 index 0000000..54da5ee --- /dev/null +++ b/routers/api/v1/repo/git_ref.go @@ -0,0 +1,107 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "net/url" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" +) + +// GetGitAllRefs get ref or an list all the refs of a repository +func GetGitAllRefs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/refs repository repoListAllGitRefs + // --- + // summary: Get specified ref or filtered repository's refs + // 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 + // responses: + // "200": + // # "$ref": "#/responses/Reference" TODO: swagger doesn't support different output formats by ref + // "$ref": "#/responses/ReferenceList" + // "404": + // "$ref": "#/responses/notFound" + + getGitRefsInternal(ctx, "") +} + +// GetGitRefs get ref or an filteresd list of refs of a repository +func GetGitRefs(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/refs/{ref} repository repoListGitRefs + // --- + // summary: Get specified ref or filtered repository's refs + // 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: ref + // in: path + // description: part or full name of the ref + // type: string + // required: true + // responses: + // "200": + // # "$ref": "#/responses/Reference" TODO: swagger doesn't support different output formats by ref + // "$ref": "#/responses/ReferenceList" + // "404": + // "$ref": "#/responses/notFound" + + getGitRefsInternal(ctx, ctx.Params("*")) +} + +func getGitRefsInternal(ctx *context.APIContext, filter string) { + refs, lastMethodName, err := utils.GetGitRefs(ctx, filter) + if err != nil { + ctx.Error(http.StatusInternalServerError, lastMethodName, err) + return + } + + if len(refs) == 0 { + ctx.NotFound() + return + } + + apiRefs := make([]*api.Reference, len(refs)) + for i := range refs { + apiRefs[i] = &api.Reference{ + Ref: refs[i].Name, + URL: ctx.Repo.Repository.APIURL() + "/git/" + util.PathEscapeSegments(refs[i].Name), + Object: &api.GitObject{ + SHA: refs[i].Object.String(), + Type: refs[i].Type, + URL: ctx.Repo.Repository.APIURL() + "/git/" + url.PathEscape(refs[i].Type) + "s/" + url.PathEscape(refs[i].Object.String()), + }, + } + } + // If single reference is found and it matches filter exactly return it as object + if len(apiRefs) == 1 && apiRefs[0].Ref == filter { + ctx.JSON(http.StatusOK, &apiRefs[0]) + return + } + ctx.JSON(http.StatusOK, &apiRefs) +} diff --git a/routers/api/v1/repo/hook.go b/routers/api/v1/repo/hook.go new file mode 100644 index 0000000..ffd2313 --- /dev/null +++ b/routers/api/v1/repo/hook.go @@ -0,0 +1,308 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + webhook_module "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" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +// ListHooks list all hooks of a repository +func ListHooks(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/hooks repository repoListHooks + // --- + // summary: List the hooks in a repository + // 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: 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/HookList" + // "404": + // "$ref": "#/responses/notFound" + + opts := &webhook.ListWebhookOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + } + + hooks, count, err := db.FindAndCount[webhook.Webhook](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiHooks := make([]*api.Hook, len(hooks)) + for i := range hooks { + apiHooks[i], err = webhook_service.ToHook(ctx.Repo.RepoLink, hooks[i]) + if err != nil { + ctx.InternalServerError(err) + return + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiHooks) +} + +// GetHook get a repo's hook by id +func GetHook(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/hooks/{id} repository repoGetHook + // --- + // summary: Get a hook + // 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: id + // in: path + // description: id of the hook to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo + hookID := ctx.ParamsInt64(":id") + hook, err := utils.GetRepoHook(ctx, repo.Repository.ID, hookID) + if err != nil { + return + } + apiHook, err := webhook_service.ToHook(repo.RepoLink, hook) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusOK, apiHook) +} + +// TestHook tests a hook +func TestHook(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/hooks/{id}/tests repository repoTestHook + // --- + // summary: Test a push webhook + // 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: id + // in: path + // description: id of the hook to test + // type: integer + // format: int64 + // required: true + // - name: ref + // in: query + // description: "The name of the commit/branch/tag, indicates which commit will be loaded to the webhook payload." + // type: string + // required: false + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + if ctx.Repo.Commit == nil { + // if repo does not have any commits, then don't send a webhook + ctx.Status(http.StatusNoContent) + return + } + + ref := git.BranchPrefix + ctx.Repo.Repository.DefaultBranch + if r := ctx.FormTrim("ref"); r != "" { + ref = r + } + + hookID := ctx.ParamsInt64(":id") + hook, err := utils.GetRepoHook(ctx, ctx.Repo.Repository.ID, hookID) + if err != nil { + return + } + + commit := convert.ToPayloadCommit(ctx, ctx.Repo.Repository, ctx.Repo.Commit) + + commitID := ctx.Repo.Commit.ID.String() + if err := webhook_service.PrepareWebhook(ctx, hook, webhook_module.HookEventPush, &api.PushPayload{ + Ref: ref, + Before: commitID, + After: commitID, + CompareURL: setting.AppURL + ctx.Repo.Repository.ComposeCompareURL(commitID, commitID), + Commits: []*api.PayloadCommit{commit}, + TotalCommits: 1, + HeadCommit: commit, + Repo: convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeNone}), + Pusher: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone), + Sender: convert.ToUserWithAccessMode(ctx, ctx.Doer, perm.AccessModeNone), + }); err != nil { + ctx.Error(http.StatusInternalServerError, "PrepareWebhook: ", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateHook create a hook for a repository +func CreateHook(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/hooks repository repoCreateHook + // --- + // summary: Create a hook + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateHookOption" + // responses: + // "201": + // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" + + utils.AddRepoHook(ctx, web.GetForm(ctx).(*api.CreateHookOption)) +} + +// EditHook modify a hook of a repository +func EditHook(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/hooks/{id} repository repoEditHook + // --- + // summary: Edit a hook in a repository + // 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: id + // in: path + // description: index of the hook + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditHookOption" + // responses: + // "200": + // "$ref": "#/responses/Hook" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.EditHookOption) + hookID := ctx.ParamsInt64(":id") + utils.EditRepoHook(ctx, form, hookID) +} + +// DeleteHook delete a hook of a repository +func DeleteHook(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/hooks/{id} repository repoDeleteHook + // --- + // summary: Delete a hook in a repository + // 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: id + // in: path + // description: id of the hook to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + if err := webhook.DeleteWebhookByRepoID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil { + if webhook.IsErrWebhookNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "DeleteWebhookByRepoID", err) + } + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/hook_test.go b/routers/api/v1/repo/hook_test.go new file mode 100644 index 0000000..a8065e4 --- /dev/null +++ b/routers/api/v1/repo/hook_test.go @@ -0,0 +1,33 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "testing" + + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestTestHook(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1/wiki/_pages") + ctx.SetParams(":id", "1") + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + contexttest.LoadRepoCommit(t, ctx) + TestHook(ctx) + assert.EqualValues(t, http.StatusNoContent, ctx.Resp.Status()) + + unittest.AssertExistsAndLoadBean(t, &webhook.HookTask{ + HookID: 1, + }, unittest.Cond("is_delivered=?", false)) +} diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go new file mode 100644 index 0000000..99cd980 --- /dev/null +++ b/routers/api/v1/repo/issue.go @@ -0,0 +1,1041 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + issue_indexer "code.gitea.io/gitea/modules/indexer/issues" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" +) + +// SearchIssues searches for issues across the repositories that the user has access to +func SearchIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/issues/search issue issueSearchIssues + // --- + // summary: Search for issues across the repositories that the user has access to + // produces: + // - application/json + // parameters: + // - name: state + // in: query + // description: whether issue is open or closed + // type: string + // - name: labels + // in: query + // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded + // type: string + // - name: milestones + // in: query + // description: comma separated list of milestone names. Fetch only issues that have any of this milestones. Non existent are discarded + // type: string + // - name: q + // in: query + // description: search string + // type: string + // - name: priority_repo_id + // in: query + // description: repository to prioritize in the results + // type: integer + // format: int64 + // - name: type + // in: query + // description: filter by type (issues / pulls) if set + // type: string + // - name: since + // in: query + // description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // - name: before + // in: query + // description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // - name: assigned + // in: query + // description: filter (issues / pulls) assigned to you, default is false + // type: boolean + // - name: created + // in: query + // description: filter (issues / pulls) created by you, default is false + // type: boolean + // - name: mentioned + // in: query + // description: filter (issues / pulls) mentioning you, default is false + // type: boolean + // - name: review_requested + // in: query + // description: filter pulls requesting your review, default is false + // type: boolean + // - name: reviewed + // in: query + // description: filter pulls reviewed by you, default is false + // type: boolean + // - name: owner + // in: query + // description: filter by owner + // type: string + // - name: team + // in: query + // description: filter by team (requires organization owner parameter to be provided) + // type: string + // - 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/IssueList" + + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + + var isClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isClosed = optional.Some(true) + case "all": + isClosed = optional.None[bool]() + default: + isClosed = optional.Some(false) + } + + var ( + repoIDs []int64 + allPublic bool + ) + { + // find repos user can access (for issue search) + opts := &repo_model.SearchRepoOptions{ + Private: false, + AllPublic: true, + TopicOnly: false, + Collaborate: optional.None[bool](), + // This needs to be a column that is not nil in fixtures or + // MySQL will return different results when sorting by null in some cases + OrderBy: db.SearchOrderByAlphabetically, + Actor: ctx.Doer, + } + if ctx.IsSigned { + opts.Private = !ctx.PublicOnly + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + opts.OwnerID = owner.ID + opts.AllLimited = false + opts.AllPublic = false + opts.Collaborate = optional.Some(false) + } + if ctx.FormString("team") != "" { + if ctx.FormString("owner") == "" { + ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") + return + } + team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest, "Team not found", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + opts.TeamID = team.ID + } + + if opts.AllPublic { + allPublic = true + opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer + } + repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err) + return + } + if len(repoIDs) == 0 { + // no repos found, don't let the indexer return all repos + repoIDs = []int64{0} + } + } + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + + var isPull optional.Option[bool] + switch ctx.FormString("type") { + case "pulls": + isPull = optional.Some(true) + case "issues": + isPull = optional.Some(false) + default: + isPull = optional.None[bool]() + } + + var includedAnyLabels []int64 + { + labels := ctx.FormTrim("labels") + var includedLabelNames []string + if len(labels) > 0 { + includedLabelNames = strings.Split(labels, ",") + } + includedAnyLabels, err = issues_model.GetLabelIDsByNames(ctx, includedLabelNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelIDsByNames", err) + return + } + } + + var includedMilestones []int64 + { + milestones := ctx.FormTrim("milestones") + var includedMilestoneNames []string + if len(milestones) > 0 { + includedMilestoneNames = strings.Split(milestones, ",") + } + includedMilestones, err = issues_model.GetMilestoneIDsByNames(ctx, includedMilestoneNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetMilestoneIDsByNames", err) + return + } + } + + // this api is also used in UI, + // so the default limit is set to fit UI needs + limit := ctx.FormInt("limit") + if limit == 0 { + limit = setting.UI.IssuePagingNum + } else if limit > setting.API.MaxResponseItems { + limit = setting.API.MaxResponseItems + } + + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: limit, + Page: ctx.FormInt("page"), + }, + Keyword: keyword, + RepoIDs: repoIDs, + AllPublic: allPublic, + IsPull: isPull, + IsClosed: isClosed, + IncludedAnyLabelIDs: includedAnyLabels, + MilestoneIDs: includedMilestones, + SortBy: issue_indexer.SortByCreatedDesc, + } + + if since != 0 { + searchOpt.UpdatedAfterUnix = optional.Some(since) + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = optional.Some(before) + } + + if ctx.IsSigned { + ctxUserID := ctx.Doer.ID + if ctx.FormBool("created") { + searchOpt.PosterID = optional.Some(ctxUserID) + } + if ctx.FormBool("assigned") { + searchOpt.AssigneeID = optional.Some(ctxUserID) + } + if ctx.FormBool("mentioned") { + searchOpt.MentionID = optional.Some(ctxUserID) + } + if ctx.FormBool("review_requested") { + searchOpt.ReviewRequestedID = optional.Some(ctxUserID) + } + if ctx.FormBool("reviewed") { + searchOpt.ReviewedID = optional.Some(ctxUserID) + } + } + + // FIXME: It's unsupported to sort by priority repo when searching by indexer, + // it's indeed an regression, but I think it is worth to support filtering by indexer first. + _ = ctx.FormInt64("priority_repo_id") + + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssues", err) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) + return + } + + ctx.SetLinkHeader(int(total), limit) + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) +} + +// ListIssues list the issues of a repository +func ListIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues issue issueListIssues + // --- + // summary: List a repository's issues + // 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: state + // in: query + // description: whether issue is open or closed + // type: string + // enum: [closed, open, all] + // - name: labels + // in: query + // description: comma separated list of labels. Fetch only issues that have any of this labels. Non existent labels are discarded + // type: string + // - name: q + // in: query + // description: search string + // type: string + // - name: type + // in: query + // description: filter by type (issues / pulls) if set + // type: string + // enum: [issues, pulls] + // - name: milestones + // in: query + // description: comma separated list of milestone names or ids. It uses names and fall back to ids. Fetch only issues that have any of this milestones. Non existent milestones are discarded + // type: string + // - name: since + // in: query + // description: Only show items updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // - name: before + // in: query + // description: Only show items updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // required: false + // - name: created_by + // in: query + // description: Only show items which were created by the given user + // type: string + // - name: assigned_by + // in: query + // description: Only show items for which the given user is assigned + // type: string + // - name: mentioned_by + // in: query + // description: Only show items in which the given user was mentioned + // type: string + // - 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/IssueList" + // "404": + // "$ref": "#/responses/notFound" + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + + var isClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isClosed = optional.Some(true) + case "all": + isClosed = optional.None[bool]() + default: + isClosed = optional.Some(false) + } + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + + var labelIDs []int64 + if split := strings.Split(ctx.FormString("labels"), ","); len(split) > 0 { + labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, split) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err) + return + } + } + + var mileIDs []int64 + if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 { + for i := range part { + // uses names and fall back to ids + // non existent milestones are discarded + mile, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, part[i]) + if err == nil { + mileIDs = append(mileIDs, mile.ID) + continue + } + if !issues_model.IsErrMilestoneNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoIDANDName", err) + return + } + id, err := strconv.ParseInt(part[i], 10, 64) + if err != nil { + continue + } + mile, err = issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, id) + if err == nil { + mileIDs = append(mileIDs, mile.ID) + continue + } + if issues_model.IsErrMilestoneNotExist(err) { + continue + } + ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err) + } + } + + listOptions := utils.GetListOptions(ctx) + + isPull := optional.None[bool]() + switch ctx.FormString("type") { + case "pulls": + isPull = optional.Some(true) + case "issues": + isPull = optional.Some(false) + } + + if isPull.Has() && !ctx.Repo.CanReadIssuesOrPulls(isPull.Value()) { + ctx.NotFound() + return + } + + if !isPull.Has() { + canReadIssues := ctx.Repo.CanRead(unit.TypeIssues) + canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests) + if !canReadIssues && !canReadPulls { + ctx.NotFound() + return + } else if !canReadIssues { + isPull = optional.Some(true) + } else if !canReadPulls { + isPull = optional.Some(false) + } + } + + // FIXME: we should be more efficient here + createdByID := getUserIDForFilter(ctx, "created_by") + if ctx.Written() { + return + } + assignedByID := getUserIDForFilter(ctx, "assigned_by") + if ctx.Written() { + return + } + mentionedByID := getUserIDForFilter(ctx, "mentioned_by") + if ctx.Written() { + return + } + + searchOpt := &issue_indexer.SearchOptions{ + Paginator: &listOptions, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + SortBy: issue_indexer.SortByCreatedDesc, + } + if since != 0 { + searchOpt.UpdatedAfterUnix = optional.Some(since) + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = optional.Some(before) + } + if len(labelIDs) == 1 && labelIDs[0] == 0 { + searchOpt.NoLabelOnly = true + } else { + for _, labelID := range labelIDs { + if labelID > 0 { + searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) + } else { + searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) + } + } + } + + if len(mileIDs) == 1 && mileIDs[0] == db.NoConditionID { + searchOpt.MilestoneIDs = []int64{0} + } else { + searchOpt.MilestoneIDs = mileIDs + } + + if createdByID > 0 { + searchOpt.PosterID = optional.Some(createdByID) + } + if assignedByID > 0 { + searchOpt.AssigneeID = optional.Some(assignedByID) + } + if mentionedByID > 0 { + searchOpt.MentionID = optional.Some(mentionedByID) + } + + ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssues", err) + return + } + issues, err := issues_model.GetIssuesByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssuesByIDs", err) + return + } + + ctx.SetLinkHeader(int(total), listOptions.PageSize) + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) +} + +func getUserIDForFilter(ctx *context.APIContext, queryName string) int64 { + userName := ctx.FormString(queryName) + if len(userName) == 0 { + return 0 + } + + user, err := user_model.GetUserByName(ctx, userName) + if user_model.IsErrUserNotExist(err) { + ctx.NotFound(err) + return 0 + } + + if err != nil { + ctx.InternalServerError(err) + return 0 + } + + return user.ID +} + +// GetIssue get an issue of a repository +func GetIssue(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index} issue issueGetIssue + // --- + // summary: Get an issue + // 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: index + // in: path + // description: index of the issue to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Issue" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue)) +} + +// CreateIssue create an issue of a repository +func CreateIssue(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues issue issueCreateIssue + // --- + // summary: Create an issue. If using deadline only the date will be taken into account, and time of day ignored. + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateIssueOption" + // responses: + // "201": + // "$ref": "#/responses/Issue" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.CreateIssueOption) + var deadlineUnix timeutil.TimeStamp + if form.Deadline != nil && ctx.Repo.CanWrite(unit.TypeIssues) { + deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix()) + } + + issue := &issues_model.Issue{ + RepoID: ctx.Repo.Repository.ID, + Repo: ctx.Repo.Repository, + Title: form.Title, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, + Content: form.Body, + Ref: form.Ref, + DeadlineUnix: deadlineUnix, + } + + assigneeIDs := make([]int64, 0) + var err error + if ctx.Repo.CanWrite(unit.TypeIssues) { + issue.MilestoneID = form.Milestone + assigneeIDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd(ctx, form.Assignee, form.Assignees) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) + } else { + ctx.Error(http.StatusInternalServerError, "AddAssigneeByName", err) + } + return + } + + // Check if the passed assignees is assignable + for _, aID := range assigneeIDs { + assignee, err := user_model.GetUserByID(ctx, aID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserByID", err) + return + } + + valid, err := access_model.CanBeAssigned(ctx, assignee, ctx.Repo.Repository, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "canBeAssigned", err) + return + } + if !valid { + ctx.Error(http.StatusUnprocessableEntity, "canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: ctx.Repo.Repository.Name}) + return + } + } + } else { + // setting labels is not allowed if user is not a writer + form.Labels = make([]int64, 0) + } + + if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "BlockedByUser", err) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) + return + } + ctx.Error(http.StatusInternalServerError, "NewIssue", err) + return + } + + if form.Closed { + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", true); err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") + return + } + ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) + return + } + } + + // Refetch from database to assign some automatic values + issue, err = issues_model.GetIssueByID(ctx, issue.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetIssueByID", err) + return + } + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue)) +} + +// EditIssue modify an issue of a repository +func EditIssue(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index} issue issueEditIssue + // --- + // summary: Edit an issue. If using deadline only the date will be taken into account, and time of day ignored. + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditIssueOption" + // responses: + // "201": + // "$ref": "#/responses/Issue" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "412": + // "$ref": "#/responses/error" + + form := web.GetForm(ctx).(*api.EditIssueOption) + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + issue.Repo = ctx.Repo.Repository + canWrite := ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) + + err = issue.LoadAttributes(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + if !issue.IsPoster(ctx.Doer.ID) && !canWrite { + ctx.Status(http.StatusForbidden) + return + } + + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + + if len(form.Title) > 0 { + err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeTitle", err) + return + } + } + if form.Body != nil { + err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, issue.ContentVersion) + if err != nil { + if errors.Is(err, issues_model.ErrIssueAlreadyChanged) { + ctx.Error(http.StatusBadRequest, "ChangeContent", err) + return + } + + ctx.Error(http.StatusInternalServerError, "ChangeContent", err) + return + } + } + if form.Ref != nil { + err = issue_service.ChangeIssueRef(ctx, issue, ctx.Doer, *form.Ref) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateRef", err) + return + } + } + + // Update or remove the deadline, only if set and allowed + if (form.Deadline != nil || form.RemoveDeadline != nil) && canWrite { + var deadlineUnix timeutil.TimeStamp + + if form.RemoveDeadline == nil || !*form.RemoveDeadline { + if form.Deadline == nil { + ctx.Error(http.StatusBadRequest, "", "The due_date cannot be empty") + return + } + if !form.Deadline.IsZero() { + deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), + 23, 59, 59, 0, form.Deadline.Location()) + deadlineUnix = timeutil.TimeStamp(deadline.Unix()) + } + } + + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) + return + } + issue.DeadlineUnix = deadlineUnix + } + + // Add/delete assignees + + // Deleting is done the GitHub way (quote from their api documentation): + // https://developer.github.com/v3/issues/#edit-an-issue + // "assignees" (array): Logins for Users to assign to this issue. + // Pass one or more user logins to replace the set of assignees on this Issue. + // Send an empty array ([]) to clear all assignees from the Issue. + + if canWrite && (form.Assignees != nil || form.Assignee != nil) { + oneAssignee := "" + if form.Assignee != nil { + oneAssignee = *form.Assignee + } + + err = issue_service.UpdateAssignees(ctx, issue, oneAssignee, form.Assignees, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) + return + } + } + + if canWrite && form.Milestone != nil && + issue.MilestoneID != *form.Milestone { + oldMilestoneID := issue.MilestoneID + issue.MilestoneID = *form.Milestone + if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err) + return + } + } + if form.State != nil { + if issue.IsPull { + if err := issue.LoadPullRequest(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "GetPullRequest", err) + return + } + if issue.PullRequest.HasMerged { + ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged") + return + } + } + isClosed := api.StateClosed == api.StateType(*form.State) + if issue.IsClosed != isClosed { + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this issue because it still has open dependencies") + return + } + ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) + return + } + } + } + + // Refetch from database to assign some automatic values + issue, err = issues_model.GetIssueByID(ctx, issue.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + if err = issue.LoadMilestone(ctx); err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, issue)) +} + +func DeleteIssue(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index} issue issueDelete + // --- + // summary: Delete an issue + // 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: index + // in: path + // description: index of issue to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByID", err) + } + return + } + + if err = issue_service.DeleteIssue(ctx, ctx.Doer, ctx.Repo.GitRepo, issue); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteIssueByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateIssueDeadline updates an issue deadline +func UpdateIssueDeadline(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline + // --- + // summary: Set an issue deadline. If set to null, the deadline is deleted. If using deadline only the date will be taken into account, and time of day ignored. + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue to create or update a deadline on + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditDeadlineOption" + // responses: + // "201": + // "$ref": "#/responses/IssueDeadline" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.EditDeadlineOption) + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Error(http.StatusForbidden, "", "Not repo writer") + return + } + + var deadlineUnix timeutil.TimeStamp + var deadline time.Time + if form.Deadline != nil && !form.Deadline.IsZero() { + deadline = time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), + 23, 59, 59, 0, time.Local) + deadlineUnix = timeutil.TimeStamp(deadline.Unix()) + } + + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) + return + } + + ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline}) +} diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go new file mode 100644 index 0000000..a972ab0 --- /dev/null +++ b/routers/api/v1/repo/issue_attachment.go @@ -0,0 +1,411 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" +) + +// GetIssueAttachment gets a single attachment of the issue +func GetIssueAttachment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueGetIssueAttachment + // --- + // summary: Get an issue attachment + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + + issue := getIssueFromContext(ctx) + if issue == nil { + return + } + + attach := getIssueAttachmentSafeRead(ctx, issue) + if attach == nil { + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) +} + +// ListIssueAttachments lists all attachments of the issue +func ListIssueAttachments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/assets issue issueListIssueAttachments + // --- + // summary: List issue's attachments + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/AttachmentList" + // "404": + // "$ref": "#/responses/error" + + issue := getIssueFromContext(ctx) + if issue == nil { + return + } + + if err := issue.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIIssue(ctx, ctx.Doer, issue).Attachments) +} + +// CreateIssueAttachment creates an attachment and saves the given file +func CreateIssueAttachment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/assets issue issueCreateIssueAttachment + // --- + // summary: Create an issue attachment + // produces: + // - application/json + // consumes: + // - multipart/form-data + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: name + // in: query + // description: name of the attachment + // type: string + // required: false + // - name: updated_at + // in: query + // description: time of the attachment's creation. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - name: attachment + // in: formData + // description: attachment to upload + // type: file + // required: true + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + issue := getIssueFromContext(ctx) + if issue == nil { + return + } + + if !canUserWriteIssueAttachment(ctx, issue) { + return + } + + updatedAt := ctx.Req.FormValue("updated_at") + if len(updatedAt) != 0 { + updated, err := time.Parse(time.RFC3339, updatedAt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "time.Parse", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, issue, &updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + } + + // Get uploaded file from request + file, header, err := ctx.Req.FormFile("attachment") + if err != nil { + ctx.Error(http.StatusInternalServerError, "FormFile", err) + return + } + defer file.Close() + + filename := header.Filename + if query := ctx.FormString("name"); query != "" { + filename = query + } + + attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: issue.ID, + NoAutoTime: issue.NoAutoTime, + CreatedUnix: issue.UpdatedUnix, + }) + if err != nil { + if upload.IsErrFileTypeForbidden(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + } + return + } + + issue.Attachments = append(issue.Attachments, attachment) + + if err := issue_service.ChangeContent(ctx, issue, ctx.Doer, issue.Content, issue.ContentVersion); err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeContent", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment)) +} + +// EditIssueAttachment updates the given attachment +func EditIssueAttachment(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueEditIssueAttachment + // --- + // summary: Edit an issue attachment + // produces: + // - application/json + // consumes: + // - 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditAttachmentOptions" + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + + attachment := getIssueAttachmentSafeWrite(ctx) + if attachment == nil { + return + } + + // do changes to attachment. only meaningful change is name. + form := web.GetForm(ctx).(*api.EditAttachmentOptions) + if form.Name != "" { + attachment.Name = form.Name + } + + if err := repo_model.UpdateAttachment(ctx, attachment); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err) + } + + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment)) +} + +// DeleteIssueAttachment delete a given attachment +func DeleteIssueAttachment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/assets/{attachment_id} issue issueDeleteIssueAttachment + // --- + // summary: Delete an issue attachment + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" + + attachment := getIssueAttachmentSafeWrite(ctx) + if attachment == nil { + return + } + + if err := repo_model.DeleteAttachment(ctx, attachment, true); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func getIssueFromContext(ctx *context.APIContext) *issues_model.Issue { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64("index")) + if err != nil { + ctx.NotFoundOrServerError("GetIssueByIndex", issues_model.IsErrIssueNotExist, err) + return nil + } + + issue.Repo = ctx.Repo.Repository + + return issue +} + +func getIssueAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment { + issue := getIssueFromContext(ctx) + if issue == nil { + return nil + } + + if !canUserWriteIssueAttachment(ctx, issue) { + return nil + } + + return getIssueAttachmentSafeRead(ctx, issue) +} + +func getIssueAttachmentSafeRead(ctx *context.APIContext, issue *issues_model.Issue) *repo_model.Attachment { + attachment, err := repo_model.GetAttachmentByID(ctx, ctx.ParamsInt64("attachment_id")) + if err != nil { + ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err) + return nil + } + if !attachmentBelongsToRepoOrIssue(ctx, attachment, issue) { + return nil + } + return attachment +} + +func canUserWriteIssueAttachment(ctx *context.APIContext, issue *issues_model.Issue) bool { + canEditIssue := ctx.IsSigned && (ctx.Doer.ID == issue.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin() || ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull)) + if !canEditIssue { + ctx.Error(http.StatusForbidden, "", "user should have permission to write issue") + return false + } + + return true +} + +func attachmentBelongsToRepoOrIssue(ctx *context.APIContext, attachment *repo_model.Attachment, issue *issues_model.Issue) bool { + if attachment.RepoID != ctx.Repo.Repository.ID { + log.Debug("Requested attachment[%d] does not belong to repo[%-v].", attachment.ID, ctx.Repo.Repository) + ctx.NotFound("no such attachment in repo") + return false + } + if attachment.IssueID == 0 { + log.Debug("Requested attachment[%d] is not in an issue.", attachment.ID) + ctx.NotFound("no such attachment in issue") + return false + } else if issue != nil && attachment.IssueID != issue.ID { + log.Debug("Requested attachment[%d] does not belong to issue[%d, #%d].", attachment.ID, issue.ID, issue.Index) + ctx.NotFound("no such attachment in issue") + return false + } + return true +} diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go new file mode 100644 index 0000000..1ff755c --- /dev/null +++ b/routers/api/v1/repo/issue_comment.go @@ -0,0 +1,691 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package repo + +import ( + stdCtx "context" + "errors" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" +) + +// ListIssueComments list all the comments of an issue +func ListIssueComments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/comments issue issueGetComments + // --- + // summary: List all comments on an issue + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: since + // in: query + // description: if provided, only comments updated since the specified time are returned. + // type: string + // format: date-time + // - name: before + // in: query + // description: if provided, only comments updated before the provided time are returned. + // type: string + // format: date-time + // responses: + // "200": + // "$ref": "#/responses/CommentList" + // "404": + // "$ref": "#/responses/notFound" + + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRawIssueByIndex", err) + return + } + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + + issue.Repo = ctx.Repo.Repository + + opts := &issues_model.FindCommentsOptions{ + IssueID: issue.ID, + Since: since, + Before: before, + Type: issues_model.CommentTypeComment, + } + + comments, err := issues_model.FindComments(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindComments", err) + return + } + + totalCount, err := issues_model.CountComments(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + if err := comments.LoadPosters(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPosters", err) + return + } + + if err := comments.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + + apiComments := make([]*api.Comment, len(comments)) + for i, comment := range comments { + comment.Issue = issue + apiComments[i] = convert.ToAPIComment(ctx, ctx.Repo.Repository, comments[i]) + } + + ctx.SetTotalCountHeader(totalCount) + ctx.JSON(http.StatusOK, &apiComments) +} + +// ListIssueCommentsAndTimeline list all the comments and events of an issue +func ListIssueCommentsAndTimeline(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/timeline issue issueGetCommentsAndTimeline + // --- + // summary: List all comments and events on an issue + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: since + // in: query + // description: if provided, only comments updated since the specified time are returned. + // type: string + // format: date-time + // - 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 + // - name: before + // in: query + // description: if provided, only comments updated before the provided time are returned. + // type: string + // format: date-time + // responses: + // "200": + // "$ref": "#/responses/TimelineList" + // "404": + // "$ref": "#/responses/notFound" + + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRawIssueByIndex", err) + return + } + issue.Repo = ctx.Repo.Repository + + opts := &issues_model.FindCommentsOptions{ + ListOptions: utils.GetListOptions(ctx), + IssueID: issue.ID, + Since: since, + Before: before, + Type: issues_model.CommentTypeUndefined, + } + + comments, err := issues_model.FindComments(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindComments", err) + return + } + + if err := comments.LoadPosters(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPosters", err) + return + } + + var apiComments []*api.TimelineComment + for _, comment := range comments { + if comment.Type != issues_model.CommentTypeCode && isXRefCommentAccessible(ctx, ctx.Doer, comment, issue.RepoID) { + comment.Issue = issue + apiComments = append(apiComments, convert.ToTimelineComment(ctx, issue.Repo, comment, ctx.Doer)) + } + } + + ctx.SetTotalCountHeader(int64(len(apiComments))) + ctx.JSON(http.StatusOK, &apiComments) +} + +func isXRefCommentAccessible(ctx stdCtx.Context, user *user_model.User, c *issues_model.Comment, issueRepoID int64) bool { + // Remove comments that the user has no permissions to see + if issues_model.CommentTypeIsRef(c.Type) && c.RefRepoID != issueRepoID && c.RefRepoID != 0 { + var err error + // Set RefRepo for description in template + c.RefRepo, err = repo_model.GetRepositoryByID(ctx, c.RefRepoID) + if err != nil { + return false + } + perm, err := access_model.GetUserRepoPermission(ctx, c.RefRepo, user) + if err != nil { + return false + } + if !perm.CanReadIssuesOrPulls(c.RefIsPull) { + return false + } + } + return true +} + +// ListRepoIssueComments returns all issue-comments for a repo +func ListRepoIssueComments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments issue issueGetRepoComments + // --- + // summary: List all comments in a repository + // 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: since + // in: query + // description: if provided, only comments updated since the provided time are returned. + // type: string + // format: date-time + // - name: before + // in: query + // description: if provided, only comments updated before the provided time are returned. + // type: string + // format: date-time + // - 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/CommentList" + // "404": + // "$ref": "#/responses/notFound" + + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + + var isPull optional.Option[bool] + canReadIssue := ctx.Repo.CanRead(unit.TypeIssues) + canReadPull := ctx.Repo.CanRead(unit.TypePullRequests) + if canReadIssue && canReadPull { + isPull = optional.None[bool]() + } else if canReadIssue { + isPull = optional.Some(false) + } else if canReadPull { + isPull = optional.Some(true) + } else { + ctx.NotFound() + return + } + + opts := &issues_model.FindCommentsOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + Type: issues_model.CommentTypeComment, + Since: since, + Before: before, + IsPull: isPull, + } + + comments, err := issues_model.FindComments(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindComments", err) + return + } + + totalCount, err := issues_model.CountComments(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + if err = comments.LoadPosters(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPosters", err) + return + } + + apiComments := make([]*api.Comment, len(comments)) + if err := comments.LoadIssues(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssues", err) + return + } + if err := comments.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + if _, err := comments.Issues().LoadRepositories(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositories", err) + return + } + for i := range comments { + apiComments[i] = convert.ToAPIComment(ctx, ctx.Repo.Repository, comments[i]) + } + + ctx.SetTotalCountHeader(totalCount) + ctx.JSON(http.StatusOK, &apiComments) +} + +// CreateIssueComment create a comment for an issue +func CreateIssueComment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/comments issue issueCreateComment + // --- + // summary: Add a comment to an issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateIssueCommentOption" + // responses: + // "201": + // "$ref": "#/responses/Comment" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.CreateIssueCommentOption) + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + return + } + + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) && !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden, "CreateIssueComment", errors.New(ctx.Locale.TrString("repo.issues.comment_on_locked"))) + return + } + + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + + comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil) + if err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "CreateIssueComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) + } + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIComment(ctx, ctx.Repo.Repository, comment)) +} + +// GetIssueComment Get a comment by ID +func GetIssueComment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id} issue issueGetComment + // --- + // summary: Get a comment + // consumes: + // - application/json + // 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: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Comment" + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + comment := ctx.Comment + + if comment.Type != issues_model.CommentTypeComment { + ctx.Status(http.StatusNoContent) + return + } + + if err := comment.LoadPoster(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "comment.LoadPoster", err) + return + } + + if err := comment.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIComment(ctx, ctx.Repo.Repository, comment)) +} + +// EditIssueComment modify a comment of an issue +func EditIssueComment(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/comments/{id} issue issueEditComment + // --- + // summary: Edit a comment + // consumes: + // - application/json + // 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: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditIssueCommentOption" + // responses: + // "200": + // "$ref": "#/responses/Comment" + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.EditIssueCommentOption) + editIssueComment(ctx, *form) +} + +// EditIssueCommentDeprecated modify a comment of an issue +func EditIssueCommentDeprecated(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/comments/{id} issue issueEditCommentDeprecated + // --- + // summary: Edit a comment + // deprecated: true + // consumes: + // - application/json + // 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: index + // in: path + // description: this parameter is ignored + // type: integer + // required: true + // - name: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditIssueCommentOption" + // responses: + // "200": + // "$ref": "#/responses/Comment" + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditIssueCommentOption) + editIssueComment(ctx, *form) +} + +func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) { + comment := ctx.Comment + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Status(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Status(http.StatusNoContent) + return + } + + err := comment.LoadIssue(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + + oldContent := comment.Content + comment.Content = form.Body + if err := issue_service.UpdateComment(ctx, comment, comment.ContentVersion, ctx.Doer, oldContent); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateComment", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIComment(ctx, ctx.Repo.Repository, comment)) +} + +// DeleteIssueComment delete a comment from an issue +func DeleteIssueComment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id} issue issueDeleteComment + // --- + // summary: Delete a comment + // 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: id + // in: path + // description: id of comment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + deleteIssueComment(ctx, issues_model.CommentTypeComment) +} + +// DeleteIssueCommentDeprecated delete a comment from an issue +func DeleteIssueCommentDeprecated(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/comments/{id} issue issueDeleteCommentDeprecated + // --- + // summary: Delete a comment + // deprecated: true + // 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: index + // in: path + // description: this parameter is ignored + // type: integer + // required: true + // - name: id + // in: path + // description: id of comment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + deleteIssueComment(ctx, issues_model.CommentTypeComment) +} + +func deleteIssueComment(ctx *context.APIContext, commentType issues_model.CommentType) { + comment := ctx.Comment + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull)) { + ctx.Status(http.StatusForbidden) + return + } else if comment.Type != commentType { + ctx.Status(http.StatusNoContent) + return + } + + if err := issue_service.DeleteComment(ctx, ctx.Doer, comment); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteCommentByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go new file mode 100644 index 0000000..c45e2eb --- /dev/null +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -0,0 +1,400 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "time" + + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" +) + +// GetIssueCommentAttachment gets a single attachment of the comment +func GetIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueGetIssueCommentAttachment + // --- + // summary: Get a comment attachment + // 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: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + + comment := ctx.Comment + attachment := getIssueCommentAttachmentSafeRead(ctx) + if attachment == nil { + return + } + if attachment.CommentID != comment.ID { + log.Debug("User requested attachment[%d] is not in comment[%d].", attachment.ID, comment.ID) + ctx.NotFound("attachment not in comment") + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attachment)) +} + +// ListIssueCommentAttachments lists all attachments of the comment +func ListIssueCommentAttachments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/assets issue issueListIssueCommentAttachments + // --- + // summary: List comment's attachments + // 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: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/AttachmentList" + // "404": + // "$ref": "#/responses/error" + comment := ctx.Comment + + if err := comment.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIAttachments(ctx.Repo.Repository, comment.Attachments)) +} + +// CreateIssueCommentAttachment creates an attachment and saves the given file +func CreateIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/comments/{id}/assets issue issueCreateIssueCommentAttachment + // --- + // summary: Create a comment attachment + // produces: + // - application/json + // consumes: + // - multipart/form-data + // 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: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: name + // in: query + // description: name of the attachment + // type: string + // required: false + // - name: updated_at + // in: query + // description: time of the attachment's creation. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - name: attachment + // in: formData + // description: attachment to upload + // type: file + // required: true + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // Check if comment exists and load comment + + if !canUserWriteIssueCommentAttachment(ctx) { + return + } + + comment := ctx.Comment + + updatedAt := ctx.Req.FormValue("updated_at") + if len(updatedAt) != 0 { + updated, err := time.Parse(time.RFC3339, updatedAt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "time.Parse", err) + return + } + err = comment.LoadIssue(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, &updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + } + + // Get uploaded file from request + file, header, err := ctx.Req.FormFile("attachment") + if err != nil { + ctx.Error(http.StatusInternalServerError, "FormFile", err) + return + } + defer file.Close() + + filename := header.Filename + if query := ctx.FormString("name"); query != "" { + filename = query + } + + attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: comment.IssueID, + CommentID: comment.ID, + NoAutoTime: comment.Issue.NoAutoTime, + CreatedUnix: comment.Issue.UpdatedUnix, + }) + if err != nil { + if upload.IsErrFileTypeForbidden(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) + } + return + } + + if err := comment.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + + if err = issue_service.UpdateComment(ctx, comment, comment.ContentVersion, ctx.Doer, comment.Content); err != nil { + ctx.ServerError("UpdateComment", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attachment)) +} + +// EditIssueCommentAttachment updates the given attachment +func EditIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueEditIssueCommentAttachment + // --- + // summary: Edit a comment attachment + // produces: + // - application/json + // consumes: + // - 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: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditAttachmentOptions" + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + attach := getIssueCommentAttachmentSafeWrite(ctx) + if attach == nil { + return + } + + form := web.GetForm(ctx).(*api.EditAttachmentOptions) + if form.Name != "" { + attach.Name = form.Name + } + + if err := repo_model.UpdateAttachment(ctx, attach); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateAttachment", attach) + } + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) +} + +// DeleteIssueCommentAttachment delete a given attachment +func DeleteIssueCommentAttachment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id}/assets/{attachment_id} issue issueDeleteIssueCommentAttachment + // --- + // summary: Delete a comment attachment + // 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: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/error" + // "423": + // "$ref": "#/responses/repoArchivedError" + attach := getIssueCommentAttachmentSafeWrite(ctx) + if attach == nil { + return + } + + if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) + return + } + ctx.Status(http.StatusNoContent) +} + +func getIssueCommentAttachmentSafeWrite(ctx *context.APIContext) *repo_model.Attachment { + if !canUserWriteIssueCommentAttachment(ctx) { + return nil + } + return getIssueCommentAttachmentSafeRead(ctx) +} + +func canUserWriteIssueCommentAttachment(ctx *context.APIContext) bool { + // ctx.Comment is assumed to be set in a safe way via a middleware + comment := ctx.Comment + + canEditComment := ctx.IsSigned && (ctx.Doer.ID == comment.PosterID || ctx.IsUserRepoAdmin() || ctx.IsUserSiteAdmin()) && ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull) + if !canEditComment { + ctx.Error(http.StatusForbidden, "", "user should have permission to edit comment") + return false + } + + return true +} + +func getIssueCommentAttachmentSafeRead(ctx *context.APIContext) *repo_model.Attachment { + // ctx.Comment is assumed to be set in a safe way via a middleware + comment := ctx.Comment + + attachment, err := repo_model.GetAttachmentByID(ctx, ctx.ParamsInt64("attachment_id")) + if err != nil { + ctx.NotFoundOrServerError("GetAttachmentByID", repo_model.IsErrAttachmentNotExist, err) + return nil + } + if !attachmentBelongsToRepoOrComment(ctx, attachment, comment) { + return nil + } + return attachment +} + +func attachmentBelongsToRepoOrComment(ctx *context.APIContext, attachment *repo_model.Attachment, comment *issues_model.Comment) bool { + if attachment.RepoID != ctx.Repo.Repository.ID { + log.Debug("Requested attachment[%d] does not belong to repo[%-v].", attachment.ID, ctx.Repo.Repository) + ctx.NotFound("no such attachment in repo") + return false + } + if attachment.IssueID == 0 || attachment.CommentID == 0 { + log.Debug("Requested attachment[%d] is not in a comment.", attachment.ID) + ctx.NotFound("no such attachment in comment") + return false + } + if comment != nil && attachment.CommentID != comment.ID { + log.Debug("Requested attachment[%d] does not belong to comment[%d].", attachment.ID, comment.ID) + ctx.NotFound("no such attachment in comment") + return false + } + return true +} diff --git a/routers/api/v1/repo/issue_dependency.go b/routers/api/v1/repo/issue_dependency.go new file mode 100644 index 0000000..c40e92c --- /dev/null +++ b/routers/api/v1/repo/issue_dependency.go @@ -0,0 +1,613 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetIssueDependencies list an issue's dependencies +func GetIssueDependencies(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/dependencies issue issueListIssueDependencies + // --- + // summary: List an issue's dependencies, i.e all issues that block this issue. + // 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: index + // in: path + // description: index of the issue + // type: string + // required: true + // - 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/IssueList" + // "404": + // "$ref": "#/responses/notFound" + + // If this issue's repository does not enable dependencies then there can be no dependencies by default + if !ctx.Repo.Repository.IsDependenciesEnabled(ctx) { + ctx.NotFound() + return + } + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("IsErrIssueNotExist", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + // 1. We must be able to read this issue + if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + limit := ctx.FormInt("limit") + if limit == 0 { + limit = setting.API.DefaultPagingNum + } else if limit > setting.API.MaxResponseItems { + limit = setting.API.MaxResponseItems + } + + canWrite := ctx.Repo.Permission.CanWriteIssuesOrPulls(issue.IsPull) + + blockerIssues := make([]*issues_model.Issue, 0, limit) + + // 2. Get the issues this issue depends on, i.e. the `<#b>`: ` <- <#b>` + blockersInfo, err := issue.BlockedByDependencies(ctx, db.ListOptions{ + Page: page, + PageSize: limit, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "BlockedByDependencies", err) + return + } + + repoPerms := make(map[int64]access_model.Permission) + repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission + for _, blocker := range blockersInfo { + // Get the permissions for this repository + // If the repo ID exists in the map, return the exist permissions + // else get the permission and add it to the map + var perm access_model.Permission + existPerm, ok := repoPerms[blocker.RepoID] + if ok { + perm = existPerm + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &blocker.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + repoPerms[blocker.RepoID] = perm + } + + // check permission + if !perm.CanReadIssuesOrPulls(blocker.Issue.IsPull) { + if !canWrite { + hiddenBlocker := &issues_model.DependencyInfo{ + Issue: issues_model.Issue{ + Title: "HIDDEN", + }, + } + blocker = hiddenBlocker + } else { + confidentialBlocker := &issues_model.DependencyInfo{ + Issue: issues_model.Issue{ + RepoID: blocker.Issue.RepoID, + Index: blocker.Index, + Title: blocker.Title, + IsClosed: blocker.IsClosed, + IsPull: blocker.IsPull, + }, + Repository: repo_model.Repository{ + ID: blocker.Issue.Repo.ID, + Name: blocker.Issue.Repo.Name, + OwnerName: blocker.Issue.Repo.OwnerName, + }, + } + confidentialBlocker.Issue.Repo = &confidentialBlocker.Repository + blocker = confidentialBlocker + } + } + blockerIssues = append(blockerIssues, &blocker.Issue) + } + + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, blockerIssues)) +} + +// CreateIssueDependency create a new issue dependencies +func CreateIssueDependency(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/dependencies issue issueCreateIssueDependencies + // --- + // summary: Make the issue in the url depend on the issue in the form. + // 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: index + // in: path + // description: index of the issue + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/IssueMeta" + // responses: + // "201": + // "$ref": "#/responses/Issue" + // "404": + // description: the issue does not exist + // "423": + // "$ref": "#/responses/repoArchivedError" + + // We want to make <:index> depend on
, i.e. <:index> is the target + target := getParamsIssue(ctx) + if ctx.Written() { + return + } + + // and represents the dependency + form := web.GetForm(ctx).(*api.IssueMeta) + dependency := getFormIssue(ctx, form) + if ctx.Written() { + return + } + + dependencyPerm := getPermissionForRepo(ctx, target.Repo) + if ctx.Written() { + return + } + + createIssueDependency(ctx, target, dependency, ctx.Repo.Permission, *dependencyPerm) + if ctx.Written() { + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, target)) +} + +// RemoveIssueDependency remove an issue dependency +func RemoveIssueDependency(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/dependencies issue issueRemoveIssueDependencies + // --- + // summary: Remove an issue dependency + // 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: index + // in: path + // description: index of the issue + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/IssueMeta" + // responses: + // "200": + // "$ref": "#/responses/Issue" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + // We want to make <:index> depend on , i.e. <:index> is the target + target := getParamsIssue(ctx) + if ctx.Written() { + return + } + + // and represents the dependency + form := web.GetForm(ctx).(*api.IssueMeta) + dependency := getFormIssue(ctx, form) + if ctx.Written() { + return + } + + dependencyPerm := getPermissionForRepo(ctx, target.Repo) + if ctx.Written() { + return + } + + removeIssueDependency(ctx, target, dependency, ctx.Repo.Permission, *dependencyPerm) + if ctx.Written() { + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, target)) +} + +// GetIssueBlocks list issues that are blocked by this issue +func GetIssueBlocks(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/blocks issue issueListBlocks + // --- + // summary: List issues that are blocked by this issue + // 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: index + // in: path + // description: index of the issue + // type: string + // required: true + // - 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/IssueList" + // "404": + // "$ref": "#/responses/notFound" + + // We need to list the issues that DEPEND on this issue not the other way round + // Therefore whether dependencies are enabled or not in this repository is potentially irrelevant. + + issue := getParamsIssue(ctx) + if ctx.Written() { + return + } + + if !ctx.Repo.Permission.CanReadIssuesOrPulls(issue.IsPull) { + ctx.NotFound() + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + limit := ctx.FormInt("limit") + if limit <= 1 { + limit = setting.API.DefaultPagingNum + } + + skip := (page - 1) * limit + max := page * limit + + deps, err := issue.BlockingDependencies(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "BlockingDependencies", err) + return + } + + var issues []*issues_model.Issue + + repoPerms := make(map[int64]access_model.Permission) + repoPerms[ctx.Repo.Repository.ID] = ctx.Repo.Permission + + for i, depMeta := range deps { + if i < skip || i >= max { + continue + } + + // Get the permissions for this repository + // If the repo ID exists in the map, return the exist permissions + // else get the permission and add it to the map + var perm access_model.Permission + existPerm, ok := repoPerms[depMeta.RepoID] + if ok { + perm = existPerm + } else { + var err error + perm, err = access_model.GetUserRepoPermission(ctx, &depMeta.Repository, ctx.Doer) + if err != nil { + ctx.ServerError("GetUserRepoPermission", err) + return + } + repoPerms[depMeta.RepoID] = perm + } + + if !perm.CanReadIssuesOrPulls(depMeta.Issue.IsPull) { + continue + } + + depMeta.Issue.Repo = &depMeta.Repository + issues = append(issues, &depMeta.Issue) + } + + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) +} + +// CreateIssueBlocking block the issue given in the body by the issue in path +func CreateIssueBlocking(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/blocks issue issueCreateIssueBlocking + // --- + // summary: Block the issue given in the body by the issue in path + // 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: index + // in: path + // description: index of the issue + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/IssueMeta" + // responses: + // "201": + // "$ref": "#/responses/Issue" + // "404": + // description: the issue does not exist + + dependency := getParamsIssue(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.IssueMeta) + target := getFormIssue(ctx, form) + if ctx.Written() { + return + } + + targetPerm := getPermissionForRepo(ctx, target.Repo) + if ctx.Written() { + return + } + + createIssueDependency(ctx, target, dependency, *targetPerm, ctx.Repo.Permission) + if ctx.Written() { + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency)) +} + +// RemoveIssueBlocking unblock the issue given in the body by the issue in path +func RemoveIssueBlocking(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/blocks issue issueRemoveIssueBlocking + // --- + // summary: Unblock the issue given in the body by the issue in path + // 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: index + // in: path + // description: index of the issue + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/IssueMeta" + // responses: + // "200": + // "$ref": "#/responses/Issue" + // "404": + // "$ref": "#/responses/notFound" + + dependency := getParamsIssue(ctx) + if ctx.Written() { + return + } + + form := web.GetForm(ctx).(*api.IssueMeta) + target := getFormIssue(ctx, form) + if ctx.Written() { + return + } + + targetPerm := getPermissionForRepo(ctx, target.Repo) + if ctx.Written() { + return + } + + removeIssueDependency(ctx, target, dependency, *targetPerm, ctx.Repo.Permission) + if ctx.Written() { + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIIssue(ctx, ctx.Doer, dependency)) +} + +func getParamsIssue(ctx *context.APIContext) *issues_model.Issue { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("IsErrIssueNotExist", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return nil + } + issue.Repo = ctx.Repo.Repository + return issue +} + +func getFormIssue(ctx *context.APIContext, form *api.IssueMeta) *issues_model.Issue { + var repo *repo_model.Repository + if form.Owner != ctx.Repo.Repository.OwnerName || form.Name != ctx.Repo.Repository.Name { + if !setting.Service.AllowCrossRepositoryDependencies { + ctx.JSON(http.StatusBadRequest, "CrossRepositoryDependencies not enabled") + return nil + } + var err error + repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, form.Owner, form.Name) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound("IsErrRepoNotExist", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByOwnerAndName", err) + } + return nil + } + } else { + repo = ctx.Repo.Repository + } + + issue, err := issues_model.GetIssueByIndex(ctx, repo.ID, form.Index) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound("IsErrIssueNotExist", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return nil + } + issue.Repo = repo + return issue +} + +func getPermissionForRepo(ctx *context.APIContext, repo *repo_model.Repository) *access_model.Permission { + if repo.ID == ctx.Repo.Repository.ID { + return &ctx.Repo.Permission + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return nil + } + + return &perm +} + +func createIssueDependency(ctx *context.APIContext, target, dependency *issues_model.Issue, targetPerm, dependencyPerm access_model.Permission) { + if target.Repo.IsArchived || !target.Repo.IsDependenciesEnabled(ctx) { + // The target's repository doesn't have dependencies enabled + ctx.NotFound() + return + } + + if !targetPerm.CanWriteIssuesOrPulls(target.IsPull) { + // We can't write to the target + ctx.NotFound() + return + } + + if !dependencyPerm.CanReadIssuesOrPulls(dependency.IsPull) { + // We can't read the dependency + ctx.NotFound() + return + } + + err := issues_model.CreateIssueDependency(ctx, ctx.Doer, target, dependency) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err) + return + } +} + +func removeIssueDependency(ctx *context.APIContext, target, dependency *issues_model.Issue, targetPerm, dependencyPerm access_model.Permission) { + if target.Repo.IsArchived || !target.Repo.IsDependenciesEnabled(ctx) { + // The target's repository doesn't have dependencies enabled + ctx.NotFound() + return + } + + if !targetPerm.CanWriteIssuesOrPulls(target.IsPull) { + // We can't write to the target + ctx.NotFound() + return + } + + if !dependencyPerm.CanReadIssuesOrPulls(dependency.IsPull) { + // We can't read the dependency + ctx.NotFound() + return + } + + err := issues_model.RemoveIssueDependency(ctx, ctx.Doer, target, dependency, issues_model.DependencyTypeBlockedBy) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CreateIssueDependency", err) + return + } +} diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go new file mode 100644 index 0000000..ae05544 --- /dev/null +++ b/routers/api/v1/repo/issue_label.go @@ -0,0 +1,385 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "reflect" + + issues_model "code.gitea.io/gitea/models/issues" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" +) + +// ListIssueLabels list all the labels of an issue +func ListIssueLabels(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/labels issue issueGetLabels + // --- + // summary: Get an issue's labels + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/LabelList" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if err := issue.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToLabelList(issue.Labels, ctx.Repo.Repository, ctx.Repo.Owner)) +} + +// AddIssueLabels add labels for an issue +func AddIssueLabels(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/labels issue issueAddLabel + // --- + // summary: Add a label to an issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/IssueLabelsOption" + // responses: + // "200": + // "$ref": "#/responses/LabelList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.IssueLabelsOption) + issue, labels, err := prepareForReplaceOrAdd(ctx, *form) + if err != nil { + return + } + + if err = issue_service.AddLabels(ctx, issue, ctx.Doer, labels); err != nil { + ctx.Error(http.StatusInternalServerError, "AddLabels", err) + return + } + + labels, err = issues_model.GetLabelsByIssueID(ctx, issue.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByIssueID", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, ctx.Repo.Owner)) +} + +// DeleteIssueLabel delete a label for an issue +func DeleteIssueLabel(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/labels/{id} issue issueRemoveLabel + // --- + // summary: Remove a label from an issue + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the label to remove + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteLabelsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.DeleteLabelsOption) + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Status(http.StatusForbidden) + return + } + + if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + + label, err := issues_model.GetLabelByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.IsErrLabelNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetLabelByID", err) + } + return + } + + if err := issue_service.RemoveLabel(ctx, issue, ctx.Doer, label); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteIssueLabel", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ReplaceIssueLabels replace labels for an issue +func ReplaceIssueLabels(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/labels issue issueReplaceLabels + // --- + // summary: Replace an issue's labels + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/IssueLabelsOption" + // responses: + // "200": + // "$ref": "#/responses/LabelList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.IssueLabelsOption) + issue, labels, err := prepareForReplaceOrAdd(ctx, *form) + if err != nil { + return + } + + if err := issue_service.ReplaceLabels(ctx, issue, ctx.Doer, labels); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplaceLabels", err) + return + } + + labels, err = issues_model.GetLabelsByIssueID(ctx, issue.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByIssueID", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, ctx.Repo.Owner)) +} + +// ClearIssueLabels delete all the labels for an issue +func ClearIssueLabels(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/labels issue issueClearLabels + // --- + // summary: Remove all labels from an issue + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteLabelsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.DeleteLabelsOption) + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Status(http.StatusForbidden) + return + } + + if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + + if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "ClearLabels", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) (*issues_model.Issue, []*issues_model.Label, error) { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return nil, nil, err + } + + var ( + labelIDs []int64 + labelNames []string + ) + for _, label := range form.Labels { + rv := reflect.ValueOf(label) + switch rv.Kind() { + case reflect.Float64: + labelIDs = append(labelIDs, int64(rv.Float())) + case reflect.String: + labelNames = append(labelNames, rv.String()) + } + } + if len(labelIDs) > 0 && len(labelNames) > 0 { + ctx.Error(http.StatusBadRequest, "InvalidLabels", "labels should be an array of strings or integers") + return nil, nil, fmt.Errorf("invalid labels") + } + if len(labelNames) > 0 { + labelIDs, err = issues_model.GetLabelIDsInRepoByNames(ctx, ctx.Repo.Repository.ID, labelNames) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelIDsInRepoByNames", err) + return nil, nil, err + } + } + + labels, err := issues_model.GetLabelsByIDs(ctx, labelIDs, "id", "repo_id", "org_id", "name", "exclusive") + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByIDs", err) + return nil, nil, err + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Status(http.StatusForbidden) + return nil, nil, nil + } + + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return nil, nil, err + } + + return issue, labels, err +} diff --git a/routers/api/v1/repo/issue_pin.go b/routers/api/v1/repo/issue_pin.go new file mode 100644 index 0000000..af3e063 --- /dev/null +++ b/routers/api/v1/repo/issue_pin.go @@ -0,0 +1,309 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// PinIssue pins a issue +func PinIssue(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/pin issue pinIssue + // --- + // summary: Pin an Issue + // 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: index + // in: path + // description: index of issue to pin + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else if issues_model.IsErrIssueMaxPinReached(err) { + ctx.Error(http.StatusBadRequest, "MaxPinReached", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + // If we don't do this, it will crash when trying to add the pin event to the comment history + err = issue.LoadRepo(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepo", err) + return + } + + err = issue.Pin(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "PinIssue", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// UnpinIssue unpins a Issue +func UnpinIssue(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/pin issue unpinIssue + // --- + // summary: Unpin an Issue + // 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: index + // in: path + // description: index of issue to unpin + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + // If we don't do this, it will crash when trying to add the unpin event to the comment history + err = issue.LoadRepo(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepo", err) + return + } + + err = issue.Unpin(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UnpinIssue", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// MoveIssuePin moves a pinned Issue to a new Position +func MoveIssuePin(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/issues/{index}/pin/{position} issue moveIssuePin + // --- + // summary: Moves the Pin to the given Position + // 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: index + // in: path + // description: index of issue + // type: integer + // format: int64 + // required: true + // - name: position + // in: path + // description: the new position + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + err = issue.MovePin(ctx, int(ctx.ParamsInt64(":position"))) + if err != nil { + ctx.Error(http.StatusInternalServerError, "MovePin", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListPinnedIssues returns a list of all pinned Issues +func ListPinnedIssues(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/pinned repository repoListPinnedIssues + // --- + // summary: List a repo's pinned issues + // 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 + // responses: + // "200": + // "$ref": "#/responses/IssueList" + // "404": + // "$ref": "#/responses/notFound" + issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPinnedIssues", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(ctx, ctx.Doer, issues)) +} + +// ListPinnedPullRequests returns a list of all pinned PRs +func ListPinnedPullRequests(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/pinned repository repoListPinnedPullRequests + // --- + // summary: List a repo's pinned pull requests + // 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 + // responses: + // "200": + // "$ref": "#/responses/PullRequestList" + // "404": + // "$ref": "#/responses/notFound" + issues, err := issues_model.GetPinnedIssues(ctx, ctx.Repo.Repository.ID, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPinnedPullRequests", err) + return + } + + apiPrs := make([]*api.PullRequest, len(issues)) + if err := issues.LoadPullRequests(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPullRequests", err) + return + } + for i, currentIssue := range issues { + pr := currentIssue.PullRequest + if err = pr.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) + return + } + + if err = pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + + apiPrs[i] = convert.ToAPIPullRequest(ctx, pr, ctx.Doer) + } + + ctx.JSON(http.StatusOK, &apiPrs) +} + +// AreNewIssuePinsAllowed returns if new issues pins are allowed +func AreNewIssuePinsAllowed(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/new_pin_allowed repository repoNewPinAllowed + // --- + // summary: Returns if new Issue Pins are allowed + // 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 + // responses: + // "200": + // "$ref": "#/responses/RepoNewIssuePinsAllowed" + // "404": + // "$ref": "#/responses/notFound" + pinsAllowed := api.NewIssuePinsAllowed{} + var err error + + pinsAllowed.Issues, err = issues_model.IsNewPinAllowed(ctx, ctx.Repo.Repository.ID, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsNewIssuePinAllowed", err) + return + } + + pinsAllowed.PullRequests, err = issues_model.IsNewPinAllowed(ctx, ctx.Repo.Repository.ID, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsNewPullRequestPinAllowed", err) + return + } + + ctx.JSON(http.StatusOK, pinsAllowed) +} diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go new file mode 100644 index 0000000..c395255 --- /dev/null +++ b/routers/api/v1/repo/issue_reaction.go @@ -0,0 +1,424 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" +) + +// GetIssueCommentReactions list reactions of a comment from an issue +func GetIssueCommentReactions(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issueGetCommentReactions + // --- + // summary: Get a list of reactions from a comment of an issue + // consumes: + // - application/json + // 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: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ReactionList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + comment := ctx.Comment + + reactions, _, err := issues_model.FindCommentReactions(ctx, comment.IssueID, comment.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindCommentReactions", err) + return + } + _, err = reactions.LoadUsers(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ReactionList.LoadUsers()", err) + return + } + + var result []api.Reaction + for _, r := range reactions { + result = append(result, api.Reaction{ + User: convert.ToUser(ctx, r.User, ctx.Doer), + Reaction: r.Type, + Created: r.CreatedUnix.AsTime(), + }) + } + + ctx.JSON(http.StatusOK, result) +} + +// PostIssueCommentReaction add a reaction to a comment of an issue +func PostIssueCommentReaction(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issuePostCommentReaction + // --- + // summary: Add a reaction to a comment of an issue + // consumes: + // - application/json + // 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: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "200": + // "$ref": "#/responses/Reaction" + // "201": + // "$ref": "#/responses/Reaction" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditReactionOption) + + changeIssueCommentReaction(ctx, *form, true) +} + +// DeleteIssueCommentReaction remove a reaction from a comment of an issue +func DeleteIssueCommentReaction(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issueDeleteCommentReaction + // --- + // summary: Remove a reaction from a comment of an issue + // consumes: + // - application/json + // 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: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "200": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditReactionOption) + + changeIssueCommentReaction(ctx, *form, false) +} + +func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) { + comment := ctx.Comment + + if comment.Issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(comment.Issue.IsPull) { + ctx.Error(http.StatusForbidden, "ChangeIssueCommentReaction", errors.New("no permission to change reaction")) + return + } + + if isCreateType { + // PostIssueCommentReaction part + reaction, err := issue_service.CreateCommentReaction(ctx, ctx.Doer, comment.Issue, comment, form.Reaction) + if err != nil { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, err.Error(), err) + } else if issues_model.IsErrReactionAlreadyExist(err) { + ctx.JSON(http.StatusOK, api.Reaction{ + User: convert.ToUser(ctx, ctx.Doer, ctx.Doer), + Reaction: reaction.Type, + Created: reaction.CreatedUnix.AsTime(), + }) + } else { + ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err) + } + return + } + + ctx.JSON(http.StatusCreated, api.Reaction{ + User: convert.ToUser(ctx, ctx.Doer, ctx.Doer), + Reaction: reaction.Type, + Created: reaction.CreatedUnix.AsTime(), + }) + } else { + // DeleteIssueCommentReaction part + err := issues_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Issue.ID, comment.ID, form.Reaction) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteCommentReaction", err) + return + } + // ToDo respond 204 + ctx.Status(http.StatusOK) + } +} + +// GetIssueReactions list reactions of an issue +func GetIssueReactions(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/reactions issue issueGetIssueReactions + // --- + // summary: Get a list reactions of an issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - 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/ReactionList" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanReadIssuesOrPulls(issue.IsPull) { + ctx.Error(http.StatusForbidden, "GetIssueReactions", errors.New("no permission to get reactions")) + return + } + + reactions, count, err := issues_model.FindIssueReactions(ctx, issue.ID, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindIssueReactions", err) + return + } + _, err = reactions.LoadUsers(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ReactionList.LoadUsers()", err) + return + } + + var result []api.Reaction + for _, r := range reactions { + result = append(result, api.Reaction{ + User: convert.ToUser(ctx, r.User, ctx.Doer), + Reaction: r.Type, + Created: r.CreatedUnix.AsTime(), + }) + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, result) +} + +// PostIssueReaction add a reaction to an issue +func PostIssueReaction(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/reactions issue issuePostIssueReaction + // --- + // summary: Add a reaction to an issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "200": + // "$ref": "#/responses/Reaction" + // "201": + // "$ref": "#/responses/Reaction" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.EditReactionOption) + changeIssueReaction(ctx, *form, true) +} + +// DeleteIssueReaction remove a reaction from an issue +func DeleteIssueReaction(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/reactions issue issueDeleteIssueReaction + // --- + // summary: Remove a reaction from an issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "200": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.EditReactionOption) + changeIssueReaction(ctx, *form, false) +} + +func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) { + issue, err := issues_model.GetIssueWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if issue.IsLocked && !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Error(http.StatusForbidden, "ChangeIssueCommentReaction", errors.New("no permission to change reaction")) + return + } + + if isCreateType { + // PostIssueReaction part + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Reaction) + if err != nil { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, err.Error(), err) + } else if issues_model.IsErrReactionAlreadyExist(err) { + ctx.JSON(http.StatusOK, api.Reaction{ + User: convert.ToUser(ctx, ctx.Doer, ctx.Doer), + Reaction: reaction.Type, + Created: reaction.CreatedUnix.AsTime(), + }) + } else { + ctx.Error(http.StatusInternalServerError, "CreateCommentReaction", err) + } + return + } + + ctx.JSON(http.StatusCreated, api.Reaction{ + User: convert.ToUser(ctx, ctx.Doer, ctx.Doer), + Reaction: reaction.Type, + Created: reaction.CreatedUnix.AsTime(), + }) + } else { + // DeleteIssueReaction part + err = issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Reaction) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteIssueReaction", err) + return + } + // ToDo respond 204 + ctx.Status(http.StatusOK) + } +} diff --git a/routers/api/v1/repo/issue_stopwatch.go b/routers/api/v1/repo/issue_stopwatch.go new file mode 100644 index 0000000..d9054e8 --- /dev/null +++ b/routers/api/v1/repo/issue_stopwatch.go @@ -0,0 +1,241 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// StartIssueStopwatch creates a stopwatch for the given issue. +func StartIssueStopwatch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/start issue issueStartStopWatch + // --- + // summary: Start stopwatch on an issue. + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue to create the stopwatch on + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // description: Not repo writer, user does not have rights to toggle stopwatch + // "404": + // "$ref": "#/responses/notFound" + // "409": + // description: Cannot start a stopwatch again if it already exists + + issue, err := prepareIssueStopwatch(ctx, false) + if err != nil { + return + } + + if err := issues_model.CreateIssueStopwatch(ctx, ctx.Doer, issue); err != nil { + ctx.Error(http.StatusInternalServerError, "CreateOrStopIssueStopwatch", err) + return + } + + ctx.Status(http.StatusCreated) +} + +// StopIssueStopwatch stops a stopwatch for the given issue. +func StopIssueStopwatch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/stop issue issueStopStopWatch + // --- + // summary: Stop an issue's existing stopwatch. + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue to stop the stopwatch on + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // description: Not repo writer, user does not have rights to toggle stopwatch + // "404": + // "$ref": "#/responses/notFound" + // "409": + // description: Cannot stop a non existent stopwatch + + issue, err := prepareIssueStopwatch(ctx, true) + if err != nil { + return + } + + if err := issues_model.FinishIssueStopwatch(ctx, ctx.Doer, issue); err != nil { + ctx.Error(http.StatusInternalServerError, "CreateOrStopIssueStopwatch", err) + return + } + + ctx.Status(http.StatusCreated) +} + +// DeleteIssueStopwatch delete a specific stopwatch +func DeleteIssueStopwatch(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/stopwatch/delete issue issueDeleteStopWatch + // --- + // summary: Delete an issue's existing stopwatch. + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue to stop the stopwatch on + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // description: Not repo writer, user does not have rights to toggle stopwatch + // "404": + // "$ref": "#/responses/notFound" + // "409": + // description: Cannot cancel a non existent stopwatch + + issue, err := prepareIssueStopwatch(ctx, true) + if err != nil { + return + } + + if err := issues_model.CancelStopwatch(ctx, ctx.Doer, issue); err != nil { + ctx.Error(http.StatusInternalServerError, "CancelStopwatch", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*issues_model.Issue, error) { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + + return nil, err + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Status(http.StatusForbidden) + return nil, errors.New("Unable to write to PRs") + } + + 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.Error(http.StatusConflict, "StopwatchExists", "cannot stop/cancel a non existent stopwatch") + err = errors.New("cannot stop/cancel a non existent stopwatch") + } else { + ctx.Error(http.StatusConflict, "StopwatchExists", "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 issue, nil +} + +// GetStopwatches get all stopwatches +func GetStopwatches(ctx *context.APIContext) { + // swagger:operation GET /user/stopwatches user userGetStopWatches + // --- + // summary: Get list of all existing stopwatches + // parameters: + // - 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 + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/StopWatchList" + + sws, err := issues_model.GetUserStopwatches(ctx, ctx.Doer.ID, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserStopwatches", err) + return + } + + count, err := issues_model.CountUserStopwatches(ctx, ctx.Doer.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiSWs, err := convert.ToStopWatches(ctx, sws) + if err != nil { + ctx.Error(http.StatusInternalServerError, "APIFormat", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiSWs) +} diff --git a/routers/api/v1/repo/issue_subscription.go b/routers/api/v1/repo/issue_subscription.go new file mode 100644 index 0000000..6b29218 --- /dev/null +++ b/routers/api/v1/repo/issue_subscription.go @@ -0,0 +1,294 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// AddIssueSubscription Subscribe user to issue +func AddIssueSubscription(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/issues/{index}/subscriptions/{user} issue issueAddSubscription + // --- + // summary: Subscribe user to issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: user + // in: path + // description: user to subscribe + // type: string + // required: true + // responses: + // "200": + // description: Already subscribed + // "201": + // description: Successfully Subscribed + // "304": + // description: User can only subscribe itself if he is no admin + // "404": + // "$ref": "#/responses/notFound" + + setIssueSubscription(ctx, true) +} + +// DelIssueSubscription Unsubscribe user from issue +func DelIssueSubscription(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/subscriptions/{user} issue issueDeleteSubscription + // --- + // summary: Unsubscribe user from issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: user + // in: path + // description: user witch unsubscribe + // type: string + // required: true + // responses: + // "200": + // description: Already unsubscribed + // "201": + // description: Successfully Unsubscribed + // "304": + // description: User can only subscribe itself if he is no admin + // "404": + // "$ref": "#/responses/notFound" + + setIssueSubscription(ctx, false) +} + +func setIssueSubscription(ctx *context.APIContext, watch bool) { + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + + return + } + + user, err := user_model.GetUserByName(ctx, ctx.Params(":user")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + + return + } + + // only admin and user for itself can change subscription + if user.ID != ctx.Doer.ID && !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden, "User", fmt.Errorf("%s is not permitted to change subscriptions for %s", ctx.Doer.Name, user.Name)) + return + } + + current, err := issues_model.CheckIssueWatch(ctx, user, issue) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CheckIssueWatch", err) + return + } + + // If watch state won't change + if current == watch { + ctx.Status(http.StatusOK) + return + } + + // Update watch state + if err := issues_model.CreateOrUpdateIssueWatch(ctx, user.ID, issue.ID, watch); err != nil { + ctx.Error(http.StatusInternalServerError, "CreateOrUpdateIssueWatch", err) + return + } + + ctx.Status(http.StatusCreated) +} + +// CheckIssueSubscription check if user is subscribed to an issue +func CheckIssueSubscription(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/subscriptions/check issue issueCheckSubscription + // --- + // summary: Check if user is subscribed to an issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/WatchInfo" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + + return + } + + watching, err := issues_model.CheckIssueWatch(ctx, ctx.Doer, issue) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusOK, api.WatchInfo{ + Subscribed: watching, + Ignored: !watching, + Reason: nil, + CreatedAt: issue.CreatedUnix.AsTime(), + URL: issue.APIURL(ctx) + "/subscriptions", + RepositoryURL: ctx.Repo.Repository.APIURL(), + }) +} + +// GetIssueSubscribers return subscribers of an issue +func GetIssueSubscribers(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/subscriptions issue issueSubscriptions + // --- + // summary: Get users who subscribed on an issue. + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + + return + } + + iwl, err := issues_model.GetIssueWatchers(ctx, issue.ID, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetIssueWatchers", err) + return + } + + userIDs := make([]int64, 0, len(iwl)) + for _, iw := range iwl { + userIDs = append(userIDs, iw.UserID) + } + + users, err := user_model.GetUsersByIDs(ctx, userIDs) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUsersByIDs", err) + return + } + apiUsers := make([]*api.User, 0, len(users)) + for _, v := range users { + apiUsers = append(apiUsers, convert.ToUser(ctx, v, ctx.Doer)) + } + + count, err := issues_model.CountIssueWatchers(ctx, issue.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CountIssueWatchers", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiUsers) +} diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go new file mode 100644 index 0000000..f83855e --- /dev/null +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -0,0 +1,633 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListTrackedTimes list all the tracked times of an issue +func ListTrackedTimes(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/times issue issueTrackedTimes + // --- + // summary: List an issue's tracked times + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: user + // in: query + // description: optional filter by user (available for issue managers) + // type: string + // - name: since + // in: query + // description: Only show times updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - name: before + // in: query + // description: Only show times updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - 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/TrackedTimeList" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + ctx.NotFound("Timetracker is disabled") + return + } + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + opts := &issues_model.FindTrackedTimesOptions{ + ListOptions: utils.GetListOptions(ctx), + RepositoryID: ctx.Repo.Repository.ID, + IssueID: issue.ID, + } + + qUser := ctx.FormTrim("user") + if qUser != "" { + user, err := user_model.GetUserByName(ctx, qUser) + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusNotFound, "User does not exist", err) + } else if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + return + } + opts.UserID = user.ID + } + + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + + cantSetUser := !ctx.Doer.IsAdmin && + opts.UserID != ctx.Doer.ID && + !ctx.IsUserRepoWriter([]unit.Type{unit.TypeIssues}) + + if cantSetUser { + if opts.UserID == 0 { + opts.UserID = ctx.Doer.ID + } else { + ctx.Error(http.StatusForbidden, "", fmt.Errorf("query by user not allowed; not enough rights")) + return + } + } + + count, err := issues_model.CountTrackedTimes(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTrackedTimes", err) + return + } + if err = trackedTimes.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) +} + +// AddTime add time manual to the given issue +func AddTime(ctx *context.APIContext) { + // swagger:operation Post /repos/{owner}/{repo}/issues/{index}/times issue issueAddTime + // --- + // summary: Add tracked time to a issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/AddTimeOption" + // responses: + // "200": + // "$ref": "#/responses/TrackedTime" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.AddTimeOption) + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { + if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + ctx.Error(http.StatusBadRequest, "", "time tracking disabled") + return + } + ctx.Status(http.StatusForbidden) + return + } + + user := ctx.Doer + if form.User != "" { + if (ctx.IsUserRepoAdmin() && ctx.Doer.Name != form.User) || ctx.Doer.IsAdmin { + // allow only RepoAdmin, Admin and User to add time + user, err = user_model.GetUserByName(ctx, form.User) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + } + } + + created := time.Time{} + if !form.Created.IsZero() { + created = form.Created + } + + trackedTime, err := issues_model.AddTime(ctx, user, issue, form.Time, created) + if err != nil { + ctx.Error(http.StatusInternalServerError, "AddTime", err) + return + } + if err = trackedTime.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToTrackedTime(ctx, user, trackedTime)) +} + +// ResetIssueTime reset time manual to the given issue +func ResetIssueTime(ctx *context.APIContext) { + // swagger:operation Delete /repos/{owner}/{repo}/issues/{index}/times issue issueResetTime + // --- + // summary: Reset a tracked time of an issue + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue to add tracked time to + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { + if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"}) + return + } + ctx.Status(http.StatusForbidden) + return + } + + err = issues_model.DeleteIssueUserTimes(ctx, issue, ctx.Doer) + if err != nil { + if db.IsErrNotExist(err) { + ctx.Error(http.StatusNotFound, "DeleteIssueUserTimes", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteIssueUserTimes", err) + } + return + } + ctx.Status(http.StatusNoContent) +} + +// DeleteTime delete a specific time by id +func DeleteTime(ctx *context.APIContext) { + // swagger:operation Delete /repos/{owner}/{repo}/issues/{index}/times/{id} issue issueDeleteTime + // --- + // summary: Delete specific tracked time + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of time to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { + if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + ctx.JSON(http.StatusBadRequest, struct{ Message string }{Message: "time tracking disabled"}) + return + } + ctx.Status(http.StatusForbidden) + return + } + + time, err := issues_model.GetTrackedTimeByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if db.IsErrNotExist(err) { + ctx.NotFound(err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTrackedTimeByID", err) + return + } + if time.Deleted { + ctx.NotFound(fmt.Errorf("tracked time [%d] already deleted", time.ID)) + return + } + + if !ctx.Doer.IsAdmin && time.UserID != ctx.Doer.ID { + // Only Admin and User itself can delete their time + ctx.Status(http.StatusForbidden) + return + } + + err = issues_model.DeleteTime(ctx, time) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteTime", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// ListTrackedTimesByUser lists all tracked times of the user +func ListTrackedTimesByUser(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/times/{user} repository userTrackedTimes + // --- + // summary: List a user's tracked times in a repo + // deprecated: true + // 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: user + // in: path + // description: username of user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/TrackedTimeList" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + ctx.Error(http.StatusBadRequest, "", "time tracking disabled") + return + } + user, err := user_model.GetUserByName(ctx, ctx.Params(":timetrackingusername")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return + } + if user == nil { + ctx.NotFound() + return + } + + if !ctx.IsUserRepoAdmin() && !ctx.Doer.IsAdmin && ctx.Doer.ID != user.ID { + ctx.Error(http.StatusForbidden, "", fmt.Errorf("query by user not allowed; not enough rights")) + return + } + + opts := &issues_model.FindTrackedTimesOptions{ + UserID: user.ID, + RepositoryID: ctx.Repo.Repository.ID, + } + + trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTrackedTimes", err) + return + } + if err = trackedTimes.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) +} + +// ListTrackedTimesByRepository lists all tracked times of the repository +func ListTrackedTimesByRepository(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/times repository repoTrackedTimes + // --- + // summary: List a repo's tracked times + // 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: user + // in: query + // description: optional filter by user (available for issue managers) + // type: string + // - name: since + // in: query + // description: Only show times updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - name: before + // in: query + // description: Only show times updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - 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/TrackedTimeList" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.Repository.IsTimetrackerEnabled(ctx) { + ctx.Error(http.StatusBadRequest, "", "time tracking disabled") + return + } + + opts := &issues_model.FindTrackedTimesOptions{ + ListOptions: utils.GetListOptions(ctx), + RepositoryID: ctx.Repo.Repository.ID, + } + + // Filters + qUser := ctx.FormTrim("user") + if qUser != "" { + user, err := user_model.GetUserByName(ctx, qUser) + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusNotFound, "User does not exist", err) + } else if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + return + } + opts.UserID = user.ID + } + + var err error + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + + cantSetUser := !ctx.Doer.IsAdmin && + opts.UserID != ctx.Doer.ID && + !ctx.IsUserRepoWriter([]unit.Type{unit.TypeIssues}) + + if cantSetUser { + if opts.UserID == 0 { + opts.UserID = ctx.Doer.ID + } else { + ctx.Error(http.StatusForbidden, "", fmt.Errorf("query by user not allowed; not enough rights")) + return + } + } + + count, err := issues_model.CountTrackedTimes(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTrackedTimes", err) + return + } + if err = trackedTimes.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) +} + +// ListMyTrackedTimes lists all tracked times of the current user +func ListMyTrackedTimes(ctx *context.APIContext) { + // swagger:operation GET /user/times user userCurrentTrackedTimes + // --- + // summary: List the current user's tracked times + // produces: + // - application/json + // parameters: + // - 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 + // - name: since + // in: query + // description: Only show times updated after the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // - name: before + // in: query + // description: Only show times updated before the given time. This is a timestamp in RFC 3339 format + // type: string + // format: date-time + // responses: + // "200": + // "$ref": "#/responses/TrackedTimeList" + + opts := &issues_model.FindTrackedTimesOptions{ + ListOptions: utils.GetListOptions(ctx), + UserID: ctx.Doer.ID, + } + + var err error + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Base); err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + + count, err := issues_model.CountTrackedTimes(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + trackedTimes, err := issues_model.GetTrackedTimes(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTrackedTimesByUser", err) + return + } + + if err = trackedTimes.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToTrackedTimeList(ctx, ctx.Doer, trackedTimes)) +} diff --git a/routers/api/v1/repo/key.go b/routers/api/v1/repo/key.go new file mode 100644 index 0000000..88444a2 --- /dev/null +++ b/routers/api/v1/repo/key.go @@ -0,0 +1,292 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package repo + +import ( + stdCtx "context" + "fmt" + "net/http" + "net/url" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// appendPrivateInformation appends the owner and key type information to api.PublicKey +func appendPrivateInformation(ctx stdCtx.Context, apiKey *api.DeployKey, key *asymkey_model.DeployKey, repository *repo_model.Repository) (*api.DeployKey, error) { + apiKey.ReadOnly = key.Mode == perm.AccessModeRead + if repository.ID == key.RepoID { + apiKey.Repository = convert.ToRepo(ctx, repository, access_model.Permission{AccessMode: key.Mode}) + } else { + repo, err := repo_model.GetRepositoryByID(ctx, key.RepoID) + if err != nil { + return apiKey, err + } + apiKey.Repository = convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: key.Mode}) + } + return apiKey, nil +} + +func composeDeployKeysAPILink(owner, name string) string { + return setting.AppURL + "api/v1/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/keys/" +} + +// ListDeployKeys list all the deploy keys of a repository +func ListDeployKeys(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/keys repository repoListKeys + // --- + // summary: List a repository's keys + // 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: key_id + // in: query + // description: the key_id to search for + // type: integer + // - name: fingerprint + // in: query + // description: fingerprint of the key + // type: string + // - 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/DeployKeyList" + // "404": + // "$ref": "#/responses/notFound" + + opts := asymkey_model.ListDeployKeysOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + KeyID: ctx.FormInt64("key_id"), + Fingerprint: ctx.FormString("fingerprint"), + } + + keys, count, err := db.FindAndCount[asymkey_model.DeployKey](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + apiKeys := make([]*api.DeployKey, len(keys)) + for i := range keys { + if err := keys[i].GetContent(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "GetContent", err) + return + } + apiKeys[i] = convert.ToDeployKey(apiLink, keys[i]) + if ctx.Doer.IsAdmin || ((ctx.Repo.Repository.ID == keys[i].RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) { + apiKeys[i], _ = appendPrivateInformation(ctx, apiKeys[i], keys[i], ctx.Repo.Repository) + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiKeys) +} + +// GetDeployKey get a deploy key by id +func GetDeployKey(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/keys/{id} repository repoGetKey + // --- + // summary: Get a repository's key by id + // 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: id + // in: path + // description: id of the key to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/DeployKey" + // "404": + // "$ref": "#/responses/notFound" + + key, err := asymkey_model.GetDeployKeyByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if asymkey_model.IsErrDeployKeyNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetDeployKeyByID", err) + } + return + } + + // this check make it more consistent + if key.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return + } + + if err = key.GetContent(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "GetContent", err) + return + } + + apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + apiKey := convert.ToDeployKey(apiLink, key) + if ctx.Doer.IsAdmin || ((ctx.Repo.Repository.ID == key.RepoID) && (ctx.Doer.ID == ctx.Repo.Owner.ID)) { + apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Repo.Repository) + } + ctx.JSON(http.StatusOK, apiKey) +} + +// HandleCheckKeyStringError handle check key error +func HandleCheckKeyStringError(ctx *context.APIContext, err error) { + if db.IsErrSSHDisabled(err) { + ctx.Error(http.StatusUnprocessableEntity, "", "SSH is disabled") + } else if asymkey_model.IsErrKeyUnableVerify(err) { + ctx.Error(http.StatusUnprocessableEntity, "", "Unable to verify key content") + } else { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid key content: %w", err)) + } +} + +// HandleAddKeyError handle add key error +func HandleAddKeyError(ctx *context.APIContext, err error) { + switch { + case asymkey_model.IsErrDeployKeyAlreadyExist(err): + ctx.Error(http.StatusUnprocessableEntity, "", "This key has already been added to this repository") + case asymkey_model.IsErrKeyAlreadyExist(err): + ctx.Error(http.StatusUnprocessableEntity, "", "Key content has been used as non-deploy key") + case asymkey_model.IsErrKeyNameAlreadyUsed(err): + ctx.Error(http.StatusUnprocessableEntity, "", "Key title has been used") + case asymkey_model.IsErrDeployKeyNameAlreadyUsed(err): + ctx.Error(http.StatusUnprocessableEntity, "", "A key with the same name already exists") + default: + ctx.Error(http.StatusInternalServerError, "AddKey", err) + } +} + +// CreateDeployKey create deploy key for a repository +func CreateDeployKey(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/keys repository repoCreateKey + // --- + // summary: Add a key to a repository + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateKeyOption" + // responses: + // "201": + // "$ref": "#/responses/DeployKey" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateKeyOption) + content, err := asymkey_model.CheckPublicKeyString(form.Key) + if err != nil { + HandleCheckKeyStringError(ctx, err) + return + } + + key, err := asymkey_model.AddDeployKey(ctx, ctx.Repo.Repository.ID, form.Title, content, form.ReadOnly) + if err != nil { + HandleAddKeyError(ctx, err) + return + } + + key.Content = content + apiLink := composeDeployKeysAPILink(ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) + ctx.JSON(http.StatusCreated, convert.ToDeployKey(apiLink, key)) +} + +// DeleteDeploykey delete deploy key for a repository +func DeleteDeploykey(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/keys/{id} repository repoDeleteKey + // --- + // summary: Delete a key from a repository + // 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: id + // in: path + // description: id of the key to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if err := asymkey_service.DeleteDeployKey(ctx, ctx.Doer, ctx.ParamsInt64(":id")); err != nil { + if asymkey_model.IsErrKeyAccessDenied(err) { + ctx.Error(http.StatusForbidden, "", "You do not have access to this key") + } else { + ctx.Error(http.StatusInternalServerError, "DeleteDeployKey", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/label.go b/routers/api/v1/repo/label.go new file mode 100644 index 0000000..b6eb51f --- /dev/null +++ b/routers/api/v1/repo/label.go @@ -0,0 +1,285 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strconv" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/label" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListLabels list all the labels of a repository +func ListLabels(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/labels issue issueListLabels + // --- + // summary: Get all of a repository's labels + // 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: 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/LabelList" + // "404": + // "$ref": "#/responses/notFound" + + labels, err := issues_model.GetLabelsByRepoID(ctx, ctx.Repo.Repository.ID, ctx.FormString("sort"), utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsByRepoID", err) + return + } + + count, err := issues_model.CountLabelsByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, convert.ToLabelList(labels, ctx.Repo.Repository, nil)) +} + +// GetLabel get label by repository and label id +func GetLabel(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/labels/{id} issue issueGetLabel + // --- + // summary: Get a single label + // 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: id + // in: path + // description: id of the label to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" + + var ( + l *issues_model.Label + err error + ) + strID := ctx.Params(":id") + if intID, err2 := strconv.ParseInt(strID, 10, 64); err2 != nil { + l, err = issues_model.GetLabelInRepoByName(ctx, ctx.Repo.Repository.ID, strID) + } else { + l, err = issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, intID) + } + if err != nil { + if issues_model.IsErrRepoLabelNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err) + } + return + } + + ctx.JSON(http.StatusOK, convert.ToLabel(l, ctx.Repo.Repository, nil)) +} + +// CreateLabel create a label for a repository +func CreateLabel(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/labels issue issueCreateLabel + // --- + // summary: Create a label + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateLabelOption" + // responses: + // "201": + // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateLabelOption) + + color, err := label.NormalizeColor(form.Color) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err) + return + } + form.Color = color + l := &issues_model.Label{ + Name: form.Name, + Exclusive: form.Exclusive, + Color: form.Color, + RepoID: ctx.Repo.Repository.ID, + Description: form.Description, + } + l.SetArchived(form.IsArchived) + if err := issues_model.NewLabel(ctx, l); err != nil { + ctx.Error(http.StatusInternalServerError, "NewLabel", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToLabel(l, ctx.Repo.Repository, nil)) +} + +// EditLabel modify a label for a repository +func EditLabel(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/labels/{id} issue issueEditLabel + // --- + // summary: Update a label + // consumes: + // - application/json + // 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: id + // in: path + // description: id of the label to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditLabelOption" + // responses: + // "200": + // "$ref": "#/responses/Label" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.EditLabelOption) + l, err := issues_model.GetLabelInRepoByID(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.IsErrRepoLabelNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetLabelByRepoID", err) + } + return + } + + if form.Name != nil { + l.Name = *form.Name + } + if form.Exclusive != nil { + l.Exclusive = *form.Exclusive + } + if form.Color != nil { + color, err := label.NormalizeColor(*form.Color) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "StringToColor", err) + return + } + l.Color = color + } + if form.Description != nil { + l.Description = *form.Description + } + l.SetArchived(form.IsArchived != nil && *form.IsArchived) + if err := issues_model.UpdateLabel(ctx, l); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateLabel", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToLabel(l, ctx.Repo.Repository, nil)) +} + +// DeleteLabel delete a label for a repository +func DeleteLabel(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/labels/{id} issue issueDeleteLabel + // --- + // summary: Delete a label + // 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: id + // in: path + // description: id of the label to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + if err := issues_model.DeleteLabel(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteLabel", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/language.go b/routers/api/v1/repo/language.go new file mode 100644 index 0000000..f1d5bbe --- /dev/null +++ b/routers/api/v1/repo/language.go @@ -0,0 +1,81 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "net/http" + "strconv" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +type languageResponse []*repo_model.LanguageStat + +func (l languageResponse) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + if _, err := buf.WriteString("{"); err != nil { + return nil, err + } + for i, lang := range l { + if i > 0 { + if _, err := buf.WriteString(","); err != nil { + return nil, err + } + } + if _, err := buf.WriteString(strconv.Quote(lang.Language)); err != nil { + return nil, err + } + if _, err := buf.WriteString(":"); err != nil { + return nil, err + } + if _, err := buf.WriteString(strconv.FormatInt(lang.Size, 10)); err != nil { + return nil, err + } + } + if _, err := buf.WriteString("}"); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +// GetLanguages returns languages and number of bytes of code written +func GetLanguages(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/languages repository repoGetLanguages + // --- + // summary: Get languages and number of bytes of code written + // 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 + // responses: + // "404": + // "$ref": "#/responses/notFound" + // "200": + // "$ref": "#/responses/LanguageStatistics" + + langs, err := repo_model.GetLanguageStats(ctx, ctx.Repo.Repository) + if err != nil { + log.Error("GetLanguageStats failed: %v", err) + ctx.InternalServerError(err) + return + } + + resp := make(languageResponse, len(langs)) + copy(resp, langs) + + ctx.JSON(http.StatusOK, resp) +} diff --git a/routers/api/v1/repo/main_test.go b/routers/api/v1/repo/main_test.go new file mode 100644 index 0000000..451f34d --- /dev/null +++ b/routers/api/v1/repo/main_test.go @@ -0,0 +1,21 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +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() + }, + }) +} diff --git a/routers/api/v1/repo/migrate.go b/routers/api/v1/repo/migrate.go new file mode 100644 index 0000000..0991723 --- /dev/null +++ b/routers/api/v1/repo/migrate.go @@ -0,0 +1,281 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/lfs" + "code.gitea.io/gitea/modules/log" + base "code.gitea.io/gitea/modules/migration" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/migrations" + notify_service "code.gitea.io/gitea/services/notify" + repo_service "code.gitea.io/gitea/services/repository" +) + +// Migrate migrate remote git repository to gitea +func Migrate(ctx *context.APIContext) { + // swagger:operation POST /repos/migrate repository repoMigrate + // --- + // summary: Migrate a remote git repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/MigrateRepoOptions" + // responses: + // "201": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "409": + // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.MigrateRepoOptions) + + // get repoOwner + var ( + repoOwner *user_model.User + err error + ) + if len(form.RepoOwner) != 0 { + repoOwner, err = user_model.GetUserByName(ctx, form.RepoOwner) + } else if form.RepoOwnerID != 0 { + repoOwner, err = user_model.GetUserByID(ctx, form.RepoOwnerID) + } else { + repoOwner = ctx.Doer + } + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetUser", err) + } + return + } + + if ctx.HasAPIError() { + ctx.Error(http.StatusUnprocessableEntity, "", ctx.GetErrMsg()) + return + } + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, repoOwner.ID, repoOwner.Name) { + return + } + + if !ctx.Doer.IsAdmin { + if !repoOwner.IsOrganization() && ctx.Doer.ID != repoOwner.ID { + ctx.Error(http.StatusForbidden, "", "Given user is not an organization.") + return + } + + if repoOwner.IsOrganization() { + // Check ownership of organization. + isOwner, err := organization.OrgFromUser(repoOwner).IsOwnedBy(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsOwnedBy", err) + return + } else if !isOwner { + ctx.Error(http.StatusForbidden, "", "Given user is not owner of organization.") + return + } + } + } + + remoteAddr, err := forms.ParseRemoteAddr(form.CloneAddr, form.AuthUsername, form.AuthPassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(remoteAddr, ctx.Doer) + } + if err != nil { + handleRemoteAddrError(ctx, err) + return + } + + gitServiceType := convert.ToGitServiceType(form.Service) + + if form.Mirror && setting.Mirror.DisableNewPull { + ctx.Error(http.StatusForbidden, "MirrorsGlobalDisabled", fmt.Errorf("the site administrator has disabled the creation of new pull mirrors")) + return + } + + if setting.Repository.DisableMigrations { + ctx.Error(http.StatusForbidden, "MigrationsGlobalDisabled", fmt.Errorf("the site administrator has disabled migrations")) + return + } + + form.LFS = form.LFS && setting.LFS.StartServer + + if form.LFS && len(form.LFSEndpoint) > 0 { + ep := lfs.DetermineEndpoint("", form.LFSEndpoint) + if ep == nil { + ctx.Error(http.StatusInternalServerError, "", ctx.Tr("repo.migrate.invalid_lfs_endpoint")) + return + } + err = migrations.IsMigrateURLAllowed(ep.String(), ctx.Doer) + if err != nil { + handleRemoteAddrError(ctx, err) + return + } + } + + opts := migrations.MigrateOptions{ + CloneAddr: remoteAddr, + RepoName: form.RepoName, + Description: form.Description, + Private: form.Private || setting.Repository.ForcePrivate, + Mirror: form.Mirror, + LFS: form.LFS, + LFSEndpoint: form.LFSEndpoint, + AuthUsername: form.AuthUsername, + AuthPassword: form.AuthPassword, + AuthToken: form.AuthToken, + Wiki: form.Wiki, + Issues: form.Issues, + Milestones: form.Milestones, + Labels: form.Labels, + Comments: form.Issues || form.PullRequests, + PullRequests: form.PullRequests, + Releases: form.Releases, + GitServiceType: gitServiceType, + MirrorInterval: form.MirrorInterval, + } + if opts.Mirror { + opts.Issues = false + opts.Milestones = false + opts.Labels = false + opts.Comments = false + opts.PullRequests = false + opts.Releases = false + } + + repo, err := repo_service.CreateRepositoryDirectly(ctx, ctx.Doer, repoOwner, repo_service.CreateRepoOptions{ + Name: opts.RepoName, + Description: opts.Description, + OriginalURL: form.CloneAddr, + GitServiceType: gitServiceType, + IsPrivate: opts.Private || setting.Repository.ForcePrivate, + IsMirror: opts.Mirror, + Status: repo_model.RepositoryBeingMigrated, + }) + if err != nil { + handleMigrateError(ctx, repoOwner, err) + return + } + + opts.MigrateToRepoID = repo.ID + + defer func() { + if e := recover(); e != nil { + var buf bytes.Buffer + fmt.Fprintf(&buf, "Handler crashed with error: %v", log.Stack(2)) + + err = errors.New(buf.String()) + } + + if err == nil { + notify_service.MigrateRepository(ctx, ctx.Doer, repoOwner, repo) + return + } + + if repo != nil { + if errDelete := repo_service.DeleteRepositoryDirectly(ctx, ctx.Doer, repo.ID); errDelete != nil { + log.Error("DeleteRepository: %v", errDelete) + } + } + }() + + if repo, err = migrations.MigrateRepository(graceful.GetManager().HammerContext(), ctx.Doer, repoOwner.Name, opts, nil); err != nil { + handleMigrateError(ctx, repoOwner, err) + return + } + + log.Trace("Repository migrated: %s/%s", repoOwner.Name, form.RepoName) + ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeAdmin})) +} + +func handleMigrateError(ctx *context.APIContext, repoOwner *user_model.User, err error) { + switch { + case repo_model.IsErrRepoAlreadyExist(err): + ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.") + case repo_model.IsErrRepoFilesAlreadyExist(err): + ctx.Error(http.StatusConflict, "", "Files already exist for this repository. Adopt them or delete them.") + case migrations.IsRateLimitError(err): + ctx.Error(http.StatusUnprocessableEntity, "", "Remote visit addressed rate limitation.") + case migrations.IsTwoFactorAuthError(err): + ctx.Error(http.StatusUnprocessableEntity, "", "Remote visit required two factors authentication.") + case repo_model.IsErrReachLimitOfRepo(err): + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("You have already reached your limit of %d repositories.", repoOwner.MaxCreationLimit())) + case db.IsErrNameReserved(err): + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The username '%s' is reserved.", err.(db.ErrNameReserved).Name)) + case db.IsErrNameCharsNotAllowed(err): + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The username '%s' contains invalid characters.", err.(db.ErrNameCharsNotAllowed).Name)) + case db.IsErrNamePatternNotAllowed(err): + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("The pattern '%s' is not allowed in a username.", err.(db.ErrNamePatternNotAllowed).Pattern)) + case models.IsErrInvalidCloneAddr(err): + ctx.Error(http.StatusUnprocessableEntity, "", err) + case base.IsErrNotSupported(err): + ctx.Error(http.StatusUnprocessableEntity, "", err) + default: + err = util.SanitizeErrorCredentialURLs(err) + if strings.Contains(err.Error(), "Authentication failed") || + strings.Contains(err.Error(), "Bad credentials") || + strings.Contains(err.Error(), "could not read Username") { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Authentication failed: %v.", err)) + } else if strings.Contains(err.Error(), "fatal:") { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Migration failed: %v.", err)) + } else { + ctx.Error(http.StatusInternalServerError, "MigrateRepository", err) + } + } +} + +func handleRemoteAddrError(ctx *context.APIContext, err error) { + if models.IsErrInvalidCloneAddr(err) { + addrErr := err.(*models.ErrInvalidCloneAddr) + switch { + case addrErr.IsURLError: + ctx.Error(http.StatusUnprocessableEntity, "", err) + case addrErr.IsPermissionDenied: + if addrErr.LocalPath { + ctx.Error(http.StatusUnprocessableEntity, "", "You are not allowed to import local repositories.") + } else { + ctx.Error(http.StatusUnprocessableEntity, "", "You can not import from disallowed hosts.") + } + case addrErr.IsInvalidPath: + ctx.Error(http.StatusUnprocessableEntity, "", "Invalid local path, it does not exist or not a directory.") + default: + ctx.Error(http.StatusInternalServerError, "ParseRemoteAddr", "Unknown error type (ErrInvalidCloneAddr): "+err.Error()) + } + } else { + ctx.Error(http.StatusInternalServerError, "ParseRemoteAddr", err) + } +} diff --git a/routers/api/v1/repo/milestone.go b/routers/api/v1/repo/milestone.go new file mode 100644 index 0000000..b953401 --- /dev/null +++ b/routers/api/v1/repo/milestone.go @@ -0,0 +1,309 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strconv" + "time" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListMilestones list milestones for a repository +func ListMilestones(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/milestones issue issueGetMilestonesList + // --- + // summary: Get all of a repository's opened milestones + // 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: state + // in: query + // description: Milestone state, Recognized values are open, closed and all. Defaults to "open" + // type: string + // - name: name + // in: query + // description: filter by milestone name + // type: string + // - 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/MilestoneList" + // "404": + // "$ref": "#/responses/notFound" + + state := api.StateType(ctx.FormString("state")) + var isClosed optional.Option[bool] + switch state { + case api.StateClosed, api.StateOpen: + isClosed = optional.Some(state == api.StateClosed) + } + + milestones, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + IsClosed: isClosed, + Name: ctx.FormString("name"), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "db.FindAndCount[issues_model.Milestone]", err) + return + } + + apiMilestones := make([]*api.Milestone, len(milestones)) + for i := range milestones { + apiMilestones[i] = convert.ToAPIMilestone(milestones[i]) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, &apiMilestones) +} + +// GetMilestone get a milestone for a repository by ID and if not available by name +func GetMilestone(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/milestones/{id} issue issueGetMilestone + // --- + // summary: Get a milestone + // 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: id + // in: path + // description: the milestone to get, identified by ID and if not available by name + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Milestone" + // "404": + // "$ref": "#/responses/notFound" + + milestone := getMilestoneByIDOrName(ctx) + if ctx.Written() { + return + } + + ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone)) +} + +// CreateMilestone create a milestone for a repository +func CreateMilestone(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/milestones issue issueCreateMilestone + // --- + // summary: Create a milestone + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateMilestoneOption" + // responses: + // "201": + // "$ref": "#/responses/Milestone" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.CreateMilestoneOption) + + if form.Deadline == nil { + defaultDeadline, _ := time.ParseInLocation("2006-01-02", "9999-12-31", time.Local) + form.Deadline = &defaultDeadline + } + + milestone := &issues_model.Milestone{ + RepoID: ctx.Repo.Repository.ID, + Name: form.Title, + Content: form.Description, + DeadlineUnix: timeutil.TimeStamp(form.Deadline.Unix()), + } + + if form.State == "closed" { + milestone.IsClosed = true + milestone.ClosedDateUnix = timeutil.TimeStampNow() + } + + if err := issues_model.NewMilestone(ctx, milestone); err != nil { + ctx.Error(http.StatusInternalServerError, "NewMilestone", err) + return + } + ctx.JSON(http.StatusCreated, convert.ToAPIMilestone(milestone)) +} + +// EditMilestone modify a milestone for a repository by ID and if not available by name +func EditMilestone(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/milestones/{id} issue issueEditMilestone + // --- + // summary: Update a milestone + // consumes: + // - application/json + // 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: id + // in: path + // description: the milestone to edit, identified by ID and if not available by name + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditMilestoneOption" + // responses: + // "200": + // "$ref": "#/responses/Milestone" + // "404": + // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.EditMilestoneOption) + milestone := getMilestoneByIDOrName(ctx) + if ctx.Written() { + return + } + + if len(form.Title) > 0 { + milestone.Name = form.Title + } + if form.Description != nil { + milestone.Content = *form.Description + } + if form.Deadline != nil && !form.Deadline.IsZero() { + milestone.DeadlineUnix = timeutil.TimeStamp(form.Deadline.Unix()) + } + + oldIsClosed := milestone.IsClosed + if form.State != nil { + milestone.IsClosed = *form.State == string(api.StateClosed) + } + + if err := issues_model.UpdateMilestone(ctx, milestone, oldIsClosed); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateMilestone", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIMilestone(milestone)) +} + +// DeleteMilestone delete a milestone for a repository by ID and if not available by name +func DeleteMilestone(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/milestones/{id} issue issueDeleteMilestone + // --- + // summary: Delete a milestone + // 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: id + // in: path + // description: the milestone to delete, identified by ID and if not available by name + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + m := getMilestoneByIDOrName(ctx) + if ctx.Written() { + return + } + + if err := issues_model.DeleteMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, m.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteMilestoneByRepoID", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// getMilestoneByIDOrName get milestone by ID and if not available by name +func getMilestoneByIDOrName(ctx *context.APIContext) *issues_model.Milestone { + mile := ctx.Params(":id") + mileID, _ := strconv.ParseInt(mile, 0, 64) + + if mileID != 0 { + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, mileID) + if err == nil { + return milestone + } else if !issues_model.IsErrMilestoneNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err) + return nil + } + } + + milestone, err := issues_model.GetMilestoneByRepoIDANDName(ctx, ctx.Repo.Repository.ID, mile) + if err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound() + return nil + } + ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err) + return nil + } + + return milestone +} diff --git a/routers/api/v1/repo/mirror.go b/routers/api/v1/repo/mirror.go new file mode 100644 index 0000000..ae727fd --- /dev/null +++ b/routers/api/v1/repo/mirror.go @@ -0,0 +1,449 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/setting" + 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/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/migrations" + mirror_service "code.gitea.io/gitea/services/mirror" +) + +// MirrorSync adds a mirrored repository to the sync queue +func MirrorSync(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/mirror-sync repository repoMirrorSync + // --- + // summary: Sync a mirrored repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to sync + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to sync + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + + repo := ctx.Repo.Repository + + if !ctx.Repo.CanWrite(unit.TypeCode) { + ctx.Error(http.StatusForbidden, "MirrorSync", "Must have write access") + } + + if !setting.Mirror.Enabled { + ctx.Error(http.StatusBadRequest, "MirrorSync", "Mirror feature is disabled") + return + } + + if _, err := repo_model.GetMirrorByRepoID(ctx, repo.ID); err != nil { + if errors.Is(err, repo_model.ErrMirrorNotExist) { + ctx.Error(http.StatusBadRequest, "MirrorSync", "Repository is not a mirror") + return + } + ctx.Error(http.StatusInternalServerError, "MirrorSync", err) + return + } + + mirror_service.AddPullMirrorToQueue(repo.ID) + + ctx.Status(http.StatusOK) +} + +// PushMirrorSync adds all push mirrored repositories to the sync queue +func PushMirrorSync(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/push_mirrors-sync repository repoPushMirrorSync + // --- + // summary: Sync all push mirrored repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to sync + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to sync + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/empty" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + + if !setting.Mirror.Enabled { + ctx.Error(http.StatusBadRequest, "PushMirrorSync", "Mirror feature is disabled") + return + } + // Get All push mirrors of a specific repo + pushMirrors, _, err := repo_model.GetPushMirrorsByRepoID(ctx, ctx.Repo.Repository.ID, db.ListOptions{}) + if err != nil { + ctx.Error(http.StatusNotFound, "PushMirrorSync", err) + return + } + for _, mirror := range pushMirrors { + ok := mirror_service.SyncPushMirror(ctx, mirror.ID) + if !ok { + ctx.Error(http.StatusInternalServerError, "PushMirrorSync", "error occurred when syncing push mirror "+mirror.RemoteName) + return + } + } + + ctx.Status(http.StatusOK) +} + +// ListPushMirrors get list of push mirrors of a repository +func ListPushMirrors(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/push_mirrors repository repoListPushMirrors + // --- + // summary: Get all push mirrors of the repository + // 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: 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/PushMirrorList" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !setting.Mirror.Enabled { + ctx.Error(http.StatusBadRequest, "GetPushMirrorsByRepoID", "Mirror feature is disabled") + return + } + + repo := ctx.Repo.Repository + // Get all push mirrors for the specified repository. + pushMirrors, count, err := repo_model.GetPushMirrorsByRepoID(ctx, repo.ID, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusNotFound, "GetPushMirrorsByRepoID", err) + return + } + + responsePushMirrors := make([]*api.PushMirror, 0, len(pushMirrors)) + for _, mirror := range pushMirrors { + m, err := convert.ToPushMirror(ctx, mirror) + if err == nil { + responsePushMirrors = append(responsePushMirrors, m) + } + } + ctx.SetLinkHeader(len(responsePushMirrors), utils.GetListOptions(ctx).PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, responsePushMirrors) +} + +// GetPushMirrorByName get push mirror of a repository by name +func GetPushMirrorByName(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/push_mirrors/{name} repository repoGetPushMirrorByRemoteName + // --- + // summary: Get push mirror of the repository by remoteName + // 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: name + // in: path + // description: remote name of push mirror + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/PushMirror" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if !setting.Mirror.Enabled { + ctx.Error(http.StatusBadRequest, "GetPushMirrorByRemoteName", "Mirror feature is disabled") + return + } + + mirrorName := ctx.Params(":name") + // Get push mirror of a specific repo by remoteName + pushMirror, exist, err := db.Get[repo_model.PushMirror](ctx, repo_model.PushMirrorOptions{ + RepoID: ctx.Repo.Repository.ID, + RemoteName: mirrorName, + }.ToConds()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetPushMirrors", err) + return + } else if !exist { + ctx.Error(http.StatusNotFound, "GetPushMirrors", nil) + return + } + + m, err := convert.ToPushMirror(ctx, pushMirror) + if err != nil { + ctx.ServerError("GetPushMirrorByRemoteName", err) + return + } + ctx.JSON(http.StatusOK, m) +} + +// AddPushMirror adds a push mirror to a repository +func AddPushMirror(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/push_mirrors repository repoAddPushMirror + // --- + // summary: add a push mirror to the repository + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreatePushMirrorOption" + // responses: + // "200": + // "$ref": "#/responses/PushMirror" + // "403": + // "$ref": "#/responses/forbidden" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + + if !setting.Mirror.Enabled { + ctx.Error(http.StatusBadRequest, "AddPushMirror", "Mirror feature is disabled") + return + } + + pushMirror := web.GetForm(ctx).(*api.CreatePushMirrorOption) + CreatePushMirror(ctx, pushMirror) +} + +// DeletePushMirrorByRemoteName deletes a push mirror from a repository by remoteName +func DeletePushMirrorByRemoteName(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/push_mirrors/{name} repository repoDeletePushMirror + // --- + // summary: deletes a push mirror from a repository by remoteName + // 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: name + // in: path + // description: remote name of the pushMirror + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "400": + // "$ref": "#/responses/error" + + if !setting.Mirror.Enabled { + ctx.Error(http.StatusBadRequest, "DeletePushMirrorByName", "Mirror feature is disabled") + return + } + + remoteName := ctx.Params(":name") + // Delete push mirror on repo by name. + err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{RepoID: ctx.Repo.Repository.ID, RemoteName: remoteName}) + if err != nil { + ctx.Error(http.StatusNotFound, "DeletePushMirrors", err) + return + } + ctx.Status(http.StatusNoContent) +} + +func CreatePushMirror(ctx *context.APIContext, mirrorOption *api.CreatePushMirrorOption) { + repo := ctx.Repo.Repository + + interval, err := time.ParseDuration(mirrorOption.Interval) + if err != nil || (interval != 0 && interval < setting.Mirror.MinInterval) { + ctx.Error(http.StatusBadRequest, "CreatePushMirror", err) + return + } + + if mirrorOption.UseSSH && !git.HasSSHExecutable { + ctx.Error(http.StatusBadRequest, "CreatePushMirror", "SSH authentication not available.") + return + } + + if mirrorOption.UseSSH && (mirrorOption.RemoteUsername != "" || mirrorOption.RemotePassword != "") { + ctx.Error(http.StatusBadRequest, "CreatePushMirror", "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'") + return + } + + address, err := forms.ParseRemoteAddr(mirrorOption.RemoteAddress, mirrorOption.RemoteUsername, mirrorOption.RemotePassword) + if err == nil { + err = migrations.IsMigrateURLAllowed(address, ctx.ContextUser) + } + if err != nil { + HandleRemoteAddressError(ctx, err) + return + } + + remoteSuffix, err := util.CryptoRandomString(10) + if err != nil { + ctx.ServerError("CryptoRandomString", err) + return + } + + remoteAddress, err := util.SanitizeURL(address) + if err != nil { + ctx.ServerError("SanitizeURL", err) + return + } + + pushMirror := &repo_model.PushMirror{ + RepoID: repo.ID, + Repo: repo, + RemoteName: fmt.Sprintf("remote_mirror_%s", remoteSuffix), + Interval: interval, + SyncOnCommit: mirrorOption.SyncOnCommit, + RemoteAddress: remoteAddress, + } + + var plainPrivateKey []byte + if mirrorOption.UseSSH { + publicKey, privateKey, err := util.GenerateSSHKeypair() + if err != nil { + ctx.ServerError("GenerateSSHKeypair", err) + return + } + plainPrivateKey = privateKey + pushMirror.PublicKey = string(publicKey) + } + + if err = db.Insert(ctx, pushMirror); err != nil { + ctx.ServerError("InsertPushMirror", err) + return + } + + if mirrorOption.UseSSH { + if err = pushMirror.SetPrivatekey(ctx, plainPrivateKey); err != nil { + ctx.ServerError("SetPrivatekey", err) + return + } + } + + // if the registration of the push mirrorOption fails remove it from the database + if err = mirror_service.AddPushMirrorRemote(ctx, pushMirror, address); err != nil { + if err := repo_model.DeletePushMirrors(ctx, repo_model.PushMirrorOptions{ID: pushMirror.ID, RepoID: pushMirror.RepoID}); err != nil { + ctx.ServerError("DeletePushMirrors", err) + return + } + ctx.ServerError("AddPushMirrorRemote", err) + return + } + m, err := convert.ToPushMirror(ctx, pushMirror) + if err != nil { + ctx.ServerError("ToPushMirror", err) + return + } + ctx.JSON(http.StatusOK, m) +} + +func HandleRemoteAddressError(ctx *context.APIContext, err error) { + if models.IsErrInvalidCloneAddr(err) { + addrErr := err.(*models.ErrInvalidCloneAddr) + switch { + case addrErr.IsProtocolInvalid: + ctx.Error(http.StatusBadRequest, "CreatePushMirror", "Invalid mirror protocol") + case addrErr.IsURLError: + ctx.Error(http.StatusBadRequest, "CreatePushMirror", "Invalid Url ") + case addrErr.IsPermissionDenied: + ctx.Error(http.StatusUnauthorized, "CreatePushMirror", "Permission denied") + default: + ctx.Error(http.StatusBadRequest, "CreatePushMirror", "Unknown error") + } + return + } +} diff --git a/routers/api/v1/repo/notes.go b/routers/api/v1/repo/notes.go new file mode 100644 index 0000000..a4a1d4e --- /dev/null +++ b/routers/api/v1/repo/notes.go @@ -0,0 +1,104 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetNote Get a note corresponding to a single commit from a repository +func GetNote(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/notes/{sha} repository repoGetNote + // --- + // summary: Get a note corresponding to a single commit from a repository + // 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: sha + // in: path + // description: a git ref or commit sha + // type: string + // required: true + // - name: verification + // in: query + // description: include verification for every commit (disable for speedup, default 'true') + // type: boolean + // - name: files + // in: query + // description: include a list of affected files for every commit (disable for speedup, default 'true') + // type: boolean + // responses: + // "200": + // "$ref": "#/responses/Note" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + sha := ctx.Params(":sha") + if !git.IsValidRefPattern(sha) { + ctx.Error(http.StatusUnprocessableEntity, "no valid ref or sha", fmt.Sprintf("no valid ref or sha: %s", sha)) + return + } + getNote(ctx, sha) +} + +func getNote(ctx *context.APIContext, identifier string) { + if ctx.Repo.GitRepo == nil { + ctx.InternalServerError(fmt.Errorf("no open git repo")) + return + } + + commitID, err := ctx.Repo.GitRepo.ConvertToGitID(identifier) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "ConvertToSHA1", err) + } + return + } + + var note git.Note + if err := git.GetNote(ctx, ctx.Repo.GitRepo, commitID.String(), ¬e); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(identifier) + return + } + ctx.Error(http.StatusInternalServerError, "GetNote", err) + return + } + + verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") + files := ctx.FormString("files") == "" || ctx.FormBool("files") + + cmt, err := convert.ToCommit(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, note.Commit, nil, + convert.ToCommitOptions{ + Stat: true, + Verification: verification, + Files: files, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ToCommit", err) + return + } + apiNote := api.Note{Message: string(note.Message), Commit: cmt} + ctx.JSON(http.StatusOK, apiNote) +} diff --git a/routers/api/v1/repo/patch.go b/routers/api/v1/repo/patch.go new file mode 100644 index 0000000..27c5c17 --- /dev/null +++ b/routers/api/v1/repo/patch.go @@ -0,0 +1,114 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models" + 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/services/context" + "code.gitea.io/gitea/services/repository/files" +) + +// ApplyDiffPatch handles API call for applying a patch +func ApplyDiffPatch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/diffpatch repository repoApplyDiffPatch + // --- + // summary: Apply diff patch to repository + // consumes: + // - application/json + // 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: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/UpdateFileOptions" + // responses: + // "200": + // "$ref": "#/responses/FileResponse" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + apiOpts := web.GetForm(ctx).(*api.ApplyDiffPatchFileOptions) + + opts := &files.ApplyDiffPatchOptions{ + Content: apiOpts.Content, + SHA: apiOpts.SHA, + Message: apiOpts.Message, + OldBranch: apiOpts.BranchName, + NewBranch: apiOpts.NewBranchName, + Committer: &files.IdentityOptions{ + Name: apiOpts.Committer.Name, + Email: apiOpts.Committer.Email, + }, + Author: &files.IdentityOptions{ + Name: apiOpts.Author.Name, + Email: 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" + } + + if !canWriteFiles(ctx, apiOpts.BranchName) { + ctx.Error(http.StatusInternalServerError, "ApplyPatch", repo_model.ErrUserDoesNotHaveAccessToRepo{ + UserID: ctx.Doer.ID, + RepoName: ctx.Repo.Repository.LowerName, + }) + return + } + + fileResponse, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts) + if err != nil { + if models.IsErrUserCannotCommit(err) || models.IsErrFilePathProtected(err) { + ctx.Error(http.StatusForbidden, "Access", err) + return + } + if git_model.IsErrBranchAlreadyExists(err) || models.IsErrFilenameInvalid(err) || models.IsErrSHADoesNotMatch(err) || + models.IsErrFilePathInvalid(err) || models.IsErrRepoFileAlreadyExists(err) { + ctx.Error(http.StatusUnprocessableEntity, "Invalid", err) + return + } + if git_model.IsErrBranchNotExist(err) || git.IsErrBranchNotExist(err) { + ctx.Error(http.StatusNotFound, "BranchDoesNotExist", err) + return + } + ctx.Error(http.StatusInternalServerError, "ApplyPatch", err) + } else { + ctx.JSON(http.StatusCreated, fileResponse) + } +} diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go new file mode 100644 index 0000000..bcb6ef2 --- /dev/null +++ b/routers/api/v1/repo/pull.go @@ -0,0 +1,1635 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" + + "code.gitea.io/gitea/models" + activities_model "code.gitea.io/gitea/models/activities" + git_model "code.gitea.io/gitea/models/git" + issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" + pull_model "code.gitea.io/gitea/models/pull" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/automerge" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + "code.gitea.io/gitea/services/gitdiff" + issue_service "code.gitea.io/gitea/services/issue" + notify_service "code.gitea.io/gitea/services/notify" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" +) + +// ListPullRequests returns a list of all PRs +func ListPullRequests(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls repository repoListPullRequests + // --- + // summary: List a repo's pull requests + // 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: state + // in: query + // description: "State of pull request: open or closed (optional)" + // type: string + // enum: [closed, open, all] + // - name: sort + // in: query + // description: "Type of sort" + // type: string + // enum: [oldest, recentupdate, leastupdate, mostcomment, leastcomment, priority] + // - name: milestone + // in: query + // description: "ID of the milestone" + // type: integer + // format: int64 + // - name: labels + // in: query + // description: "Label IDs" + // type: array + // collectionFormat: multi + // items: + // type: integer + // format: int64 + // - 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/PullRequestList" + // "404": + // "$ref": "#/responses/notFound" + + labelIDs, err := base.StringsToInt64s(ctx.FormStrings("labels")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "PullRequests", err) + return + } + listOptions := utils.GetListOptions(ctx) + prs, maxResults, err := issues_model.PullRequests(ctx, ctx.Repo.Repository.ID, &issues_model.PullRequestsOptions{ + ListOptions: listOptions, + State: ctx.FormTrim("state"), + SortType: ctx.FormTrim("sort"), + Labels: labelIDs, + MilestoneID: ctx.FormInt64("milestone"), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "PullRequests", err) + return + } + + apiPrs := make([]*api.PullRequest, len(prs)) + // NOTE: load repository first, so that issue.Repo will be filled with pr.BaseRepo + if err := prs.LoadRepositories(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositories", err) + return + } + issueList, err := prs.LoadIssues(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssues", err) + return + } + + if err := issueList.LoadLabels(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadLabels", err) + return + } + if err := issueList.LoadPosters(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPoster", err) + return + } + if err := issueList.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + if err := issueList.LoadMilestones(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadMilestones", err) + return + } + if err := issueList.LoadAssignees(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAssignees", err) + return + } + + for i := range prs { + apiPrs[i] = convert.ToAPIPullRequest(ctx, prs[i], ctx.Doer) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, &apiPrs) +} + +// GetPullRequest returns a single PR based on index +func GetPullRequest(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index} repository repoGetPullRequest + // --- + // summary: Get a pull request + // 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: index + // in: path + // description: index of the pull request to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullRequest" + // "404": + // "$ref": "#/responses/notFound" + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) + return + } + if err = pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) +} + +// GetPullRequest returns a single PR based on index +func GetPullRequestByBaseHead(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{base}/{head} repository repoGetPullRequestByBaseHead + // --- + // summary: Get a pull request by base and head + // 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: base + // in: path + // description: base of the pull request to get + // type: string + // required: true + // - name: head + // in: path + // description: head of the pull request to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullRequest" + // "404": + // "$ref": "#/responses/notFound" + + var headRepoID int64 + var headBranch string + head := ctx.Params("*") + if strings.Contains(head, ":") { + split := strings.SplitN(head, ":", 2) + headBranch = split[1] + var owner, name string + if strings.Contains(split[0], "/") { + split = strings.Split(split[0], "/") + owner = split[0] + name = split[1] + } else { + owner = split[0] + name = ctx.Repo.Repository.Name + } + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, name) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByOwnerName", err) + } + return + } + headRepoID = repo.ID + } else { + headRepoID = ctx.Repo.Repository.ID + headBranch = head + } + + pr, err := issues_model.GetPullRequestByBaseHeadInfo(ctx, ctx.Repo.Repository.ID, headRepoID, ctx.Params(":base"), headBranch) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByBaseHeadInfo", err) + } + return + } + + if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) + return + } + if err = pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) +} + +// DownloadPullDiffOrPatch render a pull's raw diff or patch +func DownloadPullDiffOrPatch(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}.{diffType} repository repoDownloadPullDiffOrPatch + // --- + // summary: Get a pull request diff or patch + // 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 + // - name: index + // in: path + // description: index of the pull request to get + // type: integer + // format: int64 + // required: true + // - name: diffType + // in: path + // description: whether the output is diff or patch + // type: string + // enum: [diff, patch] + // required: true + // - name: binary + // in: query + // description: whether to include binary file changes. if true, the diff is applicable with `git apply` + // type: boolean + // responses: + // "200": + // "$ref": "#/responses/string" + // "404": + // "$ref": "#/responses/notFound" + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.InternalServerError(err) + } + return + } + var patch bool + if ctx.Params(":diffType") == "diff" { + patch = false + } else { + patch = true + } + + binary := ctx.FormBool("binary") + + if err := pull_service.DownloadDiffOrPatch(ctx, pr, ctx, patch, binary); err != nil { + ctx.InternalServerError(err) + return + } +} + +// CreatePullRequest does what it says +func CreatePullRequest(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls repository repoCreatePullRequest + // --- + // summary: Create a pull request + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreatePullRequestOption" + // responses: + // "201": + // "$ref": "#/responses/PullRequest" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := *web.GetForm(ctx).(*api.CreatePullRequestOption) + if form.Head == form.Base { + ctx.Error(http.StatusUnprocessableEntity, "BaseHeadSame", + "Invalid PullRequest: There are no changes between the head and the base") + return + } + + var ( + repo = ctx.Repo.Repository + labelIDs []int64 + milestoneID int64 + ) + + // Get repo/branch information + headRepo, headGitRepo, compareInfo, baseBranch, headBranch := parseCompareInfo(ctx, form) + if ctx.Written() { + return + } + defer headGitRepo.Close() + + // Check if another PR exists with the same targets + existingPr, err := issues_model.GetUnmergedPullRequest(ctx, headRepo.ID, ctx.Repo.Repository.ID, headBranch, baseBranch, issues_model.PullRequestFlowGithub) + if err != nil { + if !issues_model.IsErrPullRequestNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetUnmergedPullRequest", err) + return + } + } else { + err = issues_model.ErrPullRequestAlreadyExists{ + ID: existingPr.ID, + IssueID: existingPr.Index, + HeadRepoID: existingPr.HeadRepoID, + BaseRepoID: existingPr.BaseRepoID, + HeadBranch: existingPr.HeadBranch, + BaseBranch: existingPr.BaseBranch, + } + ctx.Error(http.StatusConflict, "GetUnmergedPullRequest", err) + return + } + + if len(form.Labels) > 0 { + labels, err := issues_model.GetLabelsInRepoByIDs(ctx, ctx.Repo.Repository.ID, form.Labels) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsInRepoByIDs", err) + return + } + + labelIDs = make([]int64, 0, len(labels)) + for _, label := range labels { + labelIDs = append(labelIDs, label.ID) + } + + if ctx.Repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsInOrgByIDs(ctx, ctx.Repo.Owner.ID, form.Labels) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsInOrgByIDs", err) + return + } + + orgLabelIDs := make([]int64, 0, len(orgLabels)) + for _, orgLabel := range orgLabels { + orgLabelIDs = append(orgLabelIDs, orgLabel.ID) + } + labelIDs = append(labelIDs, orgLabelIDs...) + } + } + + if form.Milestone > 0 { + milestone, err := issues_model.GetMilestoneByRepoID(ctx, ctx.Repo.Repository.ID, form.Milestone) + if err != nil { + if issues_model.IsErrMilestoneNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetMilestoneByRepoID", err) + } + return + } + + milestoneID = milestone.ID + } + + var deadlineUnix timeutil.TimeStamp + if form.Deadline != nil { + deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix()) + } + + prIssue := &issues_model.Issue{ + RepoID: repo.ID, + Title: form.Title, + PosterID: ctx.Doer.ID, + Poster: ctx.Doer, + MilestoneID: milestoneID, + IsPull: true, + Content: form.Body, + DeadlineUnix: deadlineUnix, + } + pr := &issues_model.PullRequest{ + HeadRepoID: headRepo.ID, + BaseRepoID: repo.ID, + HeadBranch: headBranch, + BaseBranch: baseBranch, + HeadRepo: headRepo, + BaseRepo: repo, + MergeBase: compareInfo.MergeBase, + Type: issues_model.PullRequestGitea, + } + + // Get all assignee IDs + assigneeIDs, err := issues_model.MakeIDsFromAPIAssigneesToAdd(ctx, form.Assignee, form.Assignees) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) + } else { + ctx.Error(http.StatusInternalServerError, "AddAssigneeByName", err) + } + return + } + // Check if the passed assignees is assignable + for _, aID := range assigneeIDs { + assignee, err := user_model.GetUserByID(ctx, aID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserByID", err) + return + } + + valid, err := access_model.CanBeAssigned(ctx, assignee, repo, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "canBeAssigned", err) + return + } + if !valid { + ctx.Error(http.StatusUnprocessableEntity, "canBeAssigned", repo_model.ErrUserDoesNotHaveAccessToRepo{UserID: aID, RepoName: repo.Name}) + return + } + } + + if err := pull_service.NewPullRequest(ctx, repo, prIssue, labelIDs, []string{}, pr, assigneeIDs); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "BlockedByUser", err) + return + } else if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { + ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) + return + } + ctx.Error(http.StatusInternalServerError, "NewPullRequest", err) + return + } + + log.Trace("Pull request created: %d/%d", repo.ID, prIssue.ID) + ctx.JSON(http.StatusCreated, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) +} + +// EditPullRequest does what it says +func EditPullRequest(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/pulls/{index} repository repoEditPullRequest + // --- + // summary: Update a pull request. If using deadline only the date will be taken into account, and time of day ignored. + // consumes: + // - application/json + // 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: index + // in: path + // description: index of the pull request to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditPullRequestOption" + // responses: + // "201": + // "$ref": "#/responses/PullRequest" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" + // "412": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.EditPullRequestOption) + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + err = pr.LoadIssue(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + issue := pr.Issue + issue.Repo = ctx.Repo.Repository + + if err := issue.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + + if !issue.IsPoster(ctx.Doer.ID) && !ctx.Repo.CanWrite(unit.TypePullRequests) { + ctx.Status(http.StatusForbidden) + return + } + + if len(form.Title) > 0 { + err = issue_service.ChangeTitle(ctx, issue, ctx.Doer, form.Title) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeTitle", err) + return + } + } + if form.Body != nil { + err = issue_service.ChangeContent(ctx, issue, ctx.Doer, *form.Body, issue.ContentVersion) + if err != nil { + if errors.Is(err, issues_model.ErrIssueAlreadyChanged) { + ctx.Error(http.StatusBadRequest, "ChangeContent", err) + return + } + + ctx.Error(http.StatusInternalServerError, "ChangeContent", err) + return + } + } + + // Update or remove deadline if set + if form.Deadline != nil || form.RemoveDeadline != nil { + var deadlineUnix timeutil.TimeStamp + if (form.RemoveDeadline == nil || !*form.RemoveDeadline) && !form.Deadline.IsZero() { + deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), + 23, 59, 59, 0, form.Deadline.Location()) + deadlineUnix = timeutil.TimeStamp(deadline.Unix()) + } + + if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err) + return + } + issue.DeadlineUnix = deadlineUnix + } + + // Add/delete assignees + + // Deleting is done the GitHub way (quote from their api documentation): + // https://developer.github.com/v3/issues/#edit-an-issue + // "assignees" (array): Logins for Users to assign to this issue. + // Pass one or more user logins to replace the set of assignees on this Issue. + // Send an empty array ([]) to clear all assignees from the Issue. + + if ctx.Repo.CanWrite(unit.TypePullRequests) && (form.Assignees != nil || len(form.Assignee) > 0) { + err = issue_service.UpdateAssignees(ctx, issue, form.Assignee, form.Assignees, ctx.Doer) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Assignee does not exist: [name: %s]", err)) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateAssignees", err) + } + return + } + } + + if ctx.Repo.CanWrite(unit.TypePullRequests) && form.Milestone != 0 && + issue.MilestoneID != form.Milestone { + oldMilestoneID := issue.MilestoneID + issue.MilestoneID = form.Milestone + if err = issue_service.ChangeMilestoneAssign(ctx, issue, ctx.Doer, oldMilestoneID); err != nil { + ctx.Error(http.StatusInternalServerError, "ChangeMilestoneAssign", err) + return + } + } + + if ctx.Repo.CanWrite(unit.TypePullRequests) && form.Labels != nil { + labels, err := issues_model.GetLabelsInRepoByIDs(ctx, ctx.Repo.Repository.ID, form.Labels) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsInRepoByIDsError", err) + return + } + + if ctx.Repo.Owner.IsOrganization() { + orgLabels, err := issues_model.GetLabelsInOrgByIDs(ctx, ctx.Repo.Owner.ID, form.Labels) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLabelsInOrgByIDs", err) + return + } + + labels = append(labels, orgLabels...) + } + + if err = issues_model.ReplaceIssueLabels(ctx, issue, labels, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "ReplaceLabelsError", err) + return + } + } + + if form.State != nil { + if pr.HasMerged { + ctx.Error(http.StatusPreconditionFailed, "MergedPRState", "cannot change state of this pull request, it was already merged") + return + } + isClosed := api.StateClosed == api.StateType(*form.State) + if issue.IsClosed != isClosed { + if err := issue_service.ChangeStatus(ctx, issue, ctx.Doer, "", isClosed); err != nil { + if issues_model.IsErrDependenciesLeft(err) { + ctx.Error(http.StatusPreconditionFailed, "DependenciesLeft", "cannot close this pull request because it still has open dependencies") + return + } + ctx.Error(http.StatusInternalServerError, "ChangeStatus", err) + return + } + } + } + + // change pull target branch + if !pr.HasMerged && len(form.Base) != 0 && form.Base != pr.BaseBranch { + if !ctx.Repo.GitRepo.IsBranchExist(form.Base) { + ctx.Error(http.StatusNotFound, "NewBaseBranchNotExist", fmt.Errorf("new base '%s' not exist", form.Base)) + return + } + if err := pull_service.ChangeTargetBranch(ctx, pr, ctx.Doer, form.Base); err != nil { + if issues_model.IsErrPullRequestAlreadyExists(err) { + ctx.Error(http.StatusConflict, "IsErrPullRequestAlreadyExists", err) + return + } else if issues_model.IsErrIssueIsClosed(err) { + ctx.Error(http.StatusUnprocessableEntity, "IsErrIssueIsClosed", err) + return + } else if models.IsErrPullRequestHasMerged(err) { + ctx.Error(http.StatusConflict, "IsErrPullRequestHasMerged", err) + return + } + ctx.InternalServerError(err) + return + } + notify_service.PullRequestChangeTargetBranch(ctx, ctx.Doer, pr, form.Base) + } + + // update allow edits + if form.AllowMaintainerEdit != nil { + if err := pull_service.SetAllowEdits(ctx, ctx.Doer, pr, *form.AllowMaintainerEdit); err != nil { + if errors.Is(err, pull_service.ErrUserHasNoPermissionForAction) { + ctx.Error(http.StatusForbidden, "SetAllowEdits", fmt.Sprintf("SetAllowEdits: %s", err)) + return + } + ctx.ServerError("SetAllowEdits", err) + return + } + } + + // Refetch from database + pr, err = issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, pr.Index) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + // TODO this should be 200, not 201 + ctx.JSON(http.StatusCreated, convert.ToAPIPullRequest(ctx, pr, ctx.Doer)) +} + +// IsPullRequestMerged checks if a PR exists given an index +func IsPullRequestMerged(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/merge repository repoPullRequestIsMerged + // --- + // summary: Check if a pull request has been merged + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // description: pull request has been merged + // "404": + // description: pull request has not been merged + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if pr.HasMerged { + ctx.Status(http.StatusNoContent) + } + ctx.NotFound() +} + +// MergePullRequest merges a PR given an index +func MergePullRequest(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/merge repository repoMergePullRequest + // --- + // summary: Merge a pull request + // 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: index + // in: path + // description: index of the pull request to merge + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // $ref: "#/definitions/MergePullRequestOption" + // responses: + // "200": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "405": + // "$ref": "#/responses/empty" + // "409": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*forms.MergePullRequestForm) + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err := pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + + if err := pr.LoadIssue(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + pr.Issue.Repo = ctx.Repo.Repository + + if ctx.IsSigned { + // Update issue-user. + if err = activities_model.SetIssueReadBy(ctx, pr.Issue.ID, ctx.Doer.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "ReadBy", err) + return + } + } + + manuallyMerged := repo_model.MergeStyle(form.Do) == repo_model.MergeStyleManuallyMerged + + mergeCheckType := pull_service.MergeCheckTypeGeneral + if form.MergeWhenChecksSucceed { + mergeCheckType = pull_service.MergeCheckTypeAuto + } + if manuallyMerged { + mergeCheckType = pull_service.MergeCheckTypeManually + } + + // start with merging by checking + if err := pull_service.CheckPullMergeable(ctx, ctx.Doer, &ctx.Repo.Permission, pr, mergeCheckType, form.ForceMerge); err != nil { + if errors.Is(err, pull_service.ErrIsClosed) { + ctx.NotFound() + } else if errors.Is(err, pull_service.ErrUserNotAllowedToMerge) { + ctx.Error(http.StatusMethodNotAllowed, "Merge", "User not allowed to merge PR") + } else if errors.Is(err, pull_service.ErrHasMerged) { + ctx.Error(http.StatusMethodNotAllowed, "PR already merged", "") + } else if errors.Is(err, pull_service.ErrIsWorkInProgress) { + ctx.Error(http.StatusMethodNotAllowed, "PR is a work in progress", "Work in progress PRs cannot be merged") + } else if errors.Is(err, pull_service.ErrNotMergeableState) { + ctx.Error(http.StatusMethodNotAllowed, "PR not in mergeable state", "Please try again later") + } else if models.IsErrDisallowedToMerge(err) { + ctx.Error(http.StatusMethodNotAllowed, "PR is not ready to be merged", err) + } else if asymkey_service.IsErrWontSign(err) { + ctx.Error(http.StatusMethodNotAllowed, fmt.Sprintf("Protected branch %s requires signed commits but this merge would not be signed", pr.BaseBranch), err) + } else { + ctx.InternalServerError(err) + } + return + } + + // handle manually-merged mark + if manuallyMerged { + if err := pull_service.MergedManually(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, form.MergeCommitID); err != nil { + if models.IsErrInvalidMergeStyle(err) { + ctx.Error(http.StatusMethodNotAllowed, "Invalid merge style", fmt.Errorf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) + return + } + if strings.Contains(err.Error(), "Wrong commit ID") { + ctx.JSON(http.StatusConflict, err) + return + } + ctx.Error(http.StatusInternalServerError, "Manually-Merged", err) + return + } + ctx.Status(http.StatusOK) + return + } + + if len(form.Do) == 0 { + form.Do = string(repo_model.MergeStyleMerge) + } + + message := strings.TrimSpace(form.MergeTitleField) + if len(message) == 0 { + message, _, err = pull_service.GetDefaultMergeMessage(ctx, ctx.Repo.GitRepo, pr, repo_model.MergeStyle(form.Do)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetDefaultMergeMessage", err) + return + } + } + + form.MergeMessageField = strings.TrimSpace(form.MergeMessageField) + if len(form.MergeMessageField) > 0 { + message += "\n\n" + form.MergeMessageField + } + + if form.MergeWhenChecksSucceed { + scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message) + if err != nil { + if pull_model.IsErrAlreadyScheduledToAutoMerge(err) { + ctx.Error(http.StatusConflict, "ScheduleAutoMerge", err) + return + } + ctx.Error(http.StatusInternalServerError, "ScheduleAutoMerge", err) + return + } else if scheduled { + // nothing more to do ... + ctx.Status(http.StatusCreated) + return + } + } + + if err := pull_service.Merge(ctx, pr, ctx.Doer, ctx.Repo.GitRepo, repo_model.MergeStyle(form.Do), form.HeadCommitID, message, false); err != nil { + if models.IsErrInvalidMergeStyle(err) { + ctx.Error(http.StatusMethodNotAllowed, "Invalid merge style", fmt.Errorf("%s is not allowed an allowed merge style for this repository", repo_model.MergeStyle(form.Do))) + } else if models.IsErrMergeConflicts(err) { + conflictError := err.(models.ErrMergeConflicts) + ctx.JSON(http.StatusConflict, conflictError) + } else if models.IsErrRebaseConflicts(err) { + conflictError := err.(models.ErrRebaseConflicts) + ctx.JSON(http.StatusConflict, conflictError) + } else if models.IsErrMergeUnrelatedHistories(err) { + conflictError := err.(models.ErrMergeUnrelatedHistories) + ctx.JSON(http.StatusConflict, conflictError) + } else if git.IsErrPushOutOfDate(err) { + ctx.Error(http.StatusConflict, "Merge", "merge push out of date") + } else if models.IsErrSHADoesNotMatch(err) { + ctx.Error(http.StatusConflict, "Merge", "head out of date") + } else if git.IsErrPushRejected(err) { + errPushRej := err.(*git.ErrPushRejected) + if len(errPushRej.Message) == 0 { + ctx.Error(http.StatusConflict, "Merge", "PushRejected without remote error message") + } else { + ctx.Error(http.StatusConflict, "Merge", "PushRejected with remote message: "+errPushRej.Message) + } + } else { + ctx.Error(http.StatusInternalServerError, "Merge", err) + } + return + } + log.Trace("Pull request merged: %d", pr.ID) + + if form.DeleteBranchAfterMerge { + // Don't cleanup when there are other PR's that use this branch as head branch. + exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch) + if err != nil { + ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err) + return + } + if exist { + ctx.Status(http.StatusOK) + return + } + + var headRepo *git.Repository + if ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.HeadRepoID && ctx.Repo.GitRepo != nil { + headRepo = ctx.Repo.GitRepo + } else { + headRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo) + if err != nil { + ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err) + return + } + defer headRepo.Close() + } + if err := pull_service.RetargetChildrenOnMerge(ctx, ctx.Doer, pr); err != nil { + ctx.Error(http.StatusInternalServerError, "RetargetChildrenOnMerge", err) + return + } + if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, headRepo, pr.HeadBranch); err != nil { + switch { + case git.IsErrBranchNotExist(err): + ctx.NotFound(err) + case errors.Is(err, repo_service.ErrBranchIsDefault): + ctx.Error(http.StatusForbidden, "DefaultBranch", fmt.Errorf("can not delete default branch")) + case errors.Is(err, git_model.ErrBranchIsProtected): + ctx.Error(http.StatusForbidden, "IsProtectedBranch", fmt.Errorf("branch protected")) + default: + ctx.Error(http.StatusInternalServerError, "DeleteBranch", err) + } + return + } + if err := issues_model.AddDeletePRBranchComment(ctx, ctx.Doer, pr.BaseRepo, pr.Issue.ID, pr.HeadBranch); err != nil { + // Do not fail here as branch has already been deleted + log.Error("DeleteBranch: %v", err) + } + } + + ctx.Status(http.StatusOK) +} + +func parseCompareInfo(ctx *context.APIContext, form api.CreatePullRequestOption) (*repo_model.Repository, *git.Repository, *git.CompareInfo, string, string) { + baseRepo := ctx.Repo.Repository + + // Get compared branches information + // format: ...[:] + // base<-head: master...head:feature + // same repo: master...feature + + // TODO: Validate form first? + + baseBranch := form.Base + + var ( + headUser *user_model.User + headBranch string + isSameRepo bool + err error + ) + + // If there is no head repository, it means pull request between same repository. + headInfos := strings.Split(form.Head, ":") + if len(headInfos) == 1 { + isSameRepo = true + headUser = ctx.Repo.Owner + headBranch = headInfos[0] + } else if len(headInfos) == 2 { + headUser, err = user_model.GetUserByName(ctx, headInfos[0]) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("GetUserByName") + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return nil, nil, nil, "", "" + } + headBranch = headInfos[1] + // The head repository can also point to the same repo + isSameRepo = ctx.Repo.Owner.ID == headUser.ID + } else { + ctx.NotFound() + return nil, nil, nil, "", "" + } + + ctx.Repo.PullRequest.SameRepo = isSameRepo + log.Trace("Repo path: %q, base branch: %q, head branch: %q", ctx.Repo.GitRepo.Path, baseBranch, headBranch) + // Check if base branch is valid. + if !ctx.Repo.GitRepo.IsBranchExist(baseBranch) && !ctx.Repo.GitRepo.IsTagExist(baseBranch) { + ctx.NotFound("BaseNotExist") + return nil, nil, nil, "", "" + } + + // Check if current user has fork of repository or in the same repository. + headRepo := repo_model.GetForkedRepo(ctx, headUser.ID, baseRepo.ID) + if headRepo == nil && !isSameRepo { + err := baseRepo.GetBaseRepo(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBaseRepo", err) + return nil, nil, nil, "", "" + } + + // Check if baseRepo's base repository is the same as headUser's repository. + if baseRepo.BaseRepo == nil || baseRepo.BaseRepo.OwnerID != headUser.ID { + log.Trace("parseCompareInfo[%d]: does not have fork or in same repository", baseRepo.ID) + ctx.NotFound("GetBaseRepo") + return nil, nil, nil, "", "" + } + // Assign headRepo so it can be used below. + headRepo = baseRepo.BaseRepo + } + + var headGitRepo *git.Repository + if isSameRepo { + headRepo = ctx.Repo.Repository + headGitRepo = ctx.Repo.GitRepo + } else { + headGitRepo, err = gitrepo.OpenRepository(ctx, headRepo) + if err != nil { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + return nil, nil, nil, "", "" + } + } + + // user should have permission to read baseRepo's codes and pulls, NOT headRepo's + permBase, err := access_model.GetUserRepoPermission(ctx, baseRepo, ctx.Doer) + if err != nil { + headGitRepo.Close() + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return nil, nil, nil, "", "" + } + if !permBase.CanReadIssuesOrPulls(true) || !permBase.CanRead(unit.TypeCode) { + if log.IsTrace() { + log.Trace("Permission Denied: User %-v cannot create/read pull requests or cannot read code in Repo %-v\nUser in baseRepo has Permissions: %-+v", + ctx.Doer, + baseRepo, + permBase) + } + headGitRepo.Close() + ctx.NotFound("Can't read pulls or can't read UnitTypeCode") + return nil, nil, nil, "", "" + } + + // user should have permission to read headrepo's codes + permHead, err := access_model.GetUserRepoPermission(ctx, headRepo, ctx.Doer) + if err != nil { + headGitRepo.Close() + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return nil, nil, nil, "", "" + } + if !permHead.CanRead(unit.TypeCode) { + if log.IsTrace() { + log.Trace("Permission Denied: User: %-v cannot read code in Repo: %-v\nUser in headRepo has Permissions: %-+v", + ctx.Doer, + headRepo, + permHead) + } + headGitRepo.Close() + ctx.NotFound("Can't read headRepo UnitTypeCode") + return nil, nil, nil, "", "" + } + + // Check if head branch is valid. + if !headGitRepo.IsBranchExist(headBranch) && !headGitRepo.IsTagExist(headBranch) { + headGitRepo.Close() + ctx.NotFound() + return nil, nil, nil, "", "" + } + + compareInfo, err := headGitRepo.GetCompareInfo(repo_model.RepoPath(baseRepo.Owner.Name, baseRepo.Name), baseBranch, headBranch, false, false) + if err != nil { + headGitRepo.Close() + ctx.Error(http.StatusInternalServerError, "GetCompareInfo", err) + return nil, nil, nil, "", "" + } + + return headRepo, headGitRepo, compareInfo, baseBranch, headBranch +} + +// UpdatePullRequest merge PR's baseBranch into headBranch +func UpdatePullRequest(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/update repository repoUpdatePullRequest + // --- + // summary: Merge PR's baseBranch into headBranch + // 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: index + // in: path + // description: index of the pull request to get + // type: integer + // format: int64 + // required: true + // - name: style + // in: query + // description: how to update pull request + // type: string + // enum: [merge, rebase] + // responses: + // "200": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if pr.HasMerged { + ctx.Error(http.StatusUnprocessableEntity, "UpdatePullRequest", err) + return + } + + if err = pr.LoadIssue(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + + if pr.Issue.IsClosed { + ctx.Error(http.StatusUnprocessableEntity, "UpdatePullRequest", err) + return + } + + if err = pr.LoadBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadBaseRepo", err) + return + } + if err = pr.LoadHeadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadHeadRepo", err) + return + } + + rebase := ctx.FormString("style") == "rebase" + + allowedUpdateByMerge, allowedUpdateByRebase, err := pull_service.IsUserAllowedToUpdate(ctx, pr, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "IsUserAllowedToMerge", err) + return + } + + if (!allowedUpdateByMerge && !rebase) || (rebase && !allowedUpdateByRebase) { + ctx.Status(http.StatusForbidden) + return + } + + // 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 models.IsErrMergeConflicts(err) { + ctx.Error(http.StatusConflict, "Update", "merge failed because of conflict") + return + } else if models.IsErrRebaseConflicts(err) { + ctx.Error(http.StatusConflict, "Update", "rebase failed because of conflict") + return + } + ctx.Error(http.StatusInternalServerError, "pull_service.Update", err) + return + } + + ctx.Status(http.StatusOK) +} + +// MergePullRequest cancel an auto merge scheduled for a given PullRequest by index +func CancelScheduledAutoMerge(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/merge repository repoCancelScheduledAutoMerge + // --- + // summary: Cancel the scheduled auto merge for the given pull request + // 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: index + // in: path + // description: index of the pull request to merge + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + pullIndex := ctx.ParamsInt64(":index") + pull, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, pullIndex) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + return + } + ctx.InternalServerError(err) + return + } + + exist, autoMerge, err := pull_model.GetScheduledMergeByPullID(ctx, pull.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + if !exist { + ctx.NotFound() + return + } + + if ctx.Doer.ID != autoMerge.DoerID { + allowed, err := access_model.IsUserRepoAdmin(ctx, ctx.Repo.Repository, ctx.Doer) + if err != nil { + ctx.InternalServerError(err) + return + } + if !allowed { + ctx.Error(http.StatusForbidden, "No permission to cancel", "user has no permission to cancel the scheduled auto merge") + return + } + } + + if err := automerge.RemoveScheduledAutoMerge(ctx, ctx.Doer, pull); err != nil { + ctx.InternalServerError(err) + } else { + ctx.Status(http.StatusNoContent) + } +} + +// GetPullRequestCommits gets all commits associated with a given PR +func GetPullRequestCommits(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/commits repository repoGetPullRequestCommits + // --- + // summary: Get commits for a pull request + // 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: index + // in: path + // description: index of the pull request to get + // type: integer + // format: int64 + // required: true + // - 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 + // - name: verification + // in: query + // description: include verification for every commit (disable for speedup, default 'true') + // type: boolean + // - name: files + // in: query + // description: include a list of affected files for every commit (disable for speedup, default 'true') + // type: boolean + // responses: + // "200": + // "$ref": "#/responses/CommitList" + // "404": + // "$ref": "#/responses/notFound" + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err := pr.LoadBaseRepo(ctx); err != nil { + ctx.InternalServerError(err) + return + } + + var prInfo *git.CompareInfo + baseGitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo) + if err != nil { + ctx.ServerError("OpenRepository", err) + return + } + defer closer.Close() + + if pr.HasMerged { + prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.MergeBase, pr.GetGitRefName(), false, false) + } else { + prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.GetGitRefName(), false, false) + } + if err != nil { + ctx.ServerError("GetCompareInfo", err) + return + } + commits := prInfo.Commits + + listOptions := utils.GetListOptions(ctx) + + totalNumberOfCommits := len(commits) + totalNumberOfPages := int(math.Ceil(float64(totalNumberOfCommits) / float64(listOptions.PageSize))) + + userCache := make(map[string]*user_model.User) + + start, limit := listOptions.GetSkipTake() + + limit = min(limit, totalNumberOfCommits-start) + limit = max(limit, 0) + + verification := ctx.FormString("verification") == "" || ctx.FormBool("verification") + files := ctx.FormString("files") == "" || ctx.FormBool("files") + + apiCommits := make([]*api.Commit, 0, limit) + for i := start; i < start+limit; i++ { + apiCommit, err := convert.ToCommit(ctx, ctx.Repo.Repository, baseGitRepo, commits[i], userCache, + convert.ToCommitOptions{ + Stat: true, + Verification: verification, + Files: files, + }) + if err != nil { + ctx.ServerError("toCommit", err) + return + } + apiCommits = append(apiCommits, apiCommit) + } + + ctx.SetLinkHeader(totalNumberOfCommits, listOptions.PageSize) + ctx.SetTotalCountHeader(int64(totalNumberOfCommits)) + + ctx.RespHeader().Set("X-Page", strconv.Itoa(listOptions.Page)) + ctx.RespHeader().Set("X-PerPage", strconv.Itoa(listOptions.PageSize)) + ctx.RespHeader().Set("X-PageCount", strconv.Itoa(totalNumberOfPages)) + ctx.RespHeader().Set("X-HasMore", strconv.FormatBool(listOptions.Page < totalNumberOfPages)) + ctx.AppendAccessControlExposeHeaders("X-Page", "X-PerPage", "X-PageCount", "X-HasMore") + + ctx.JSON(http.StatusOK, &apiCommits) +} + +// GetPullRequestFiles gets all changed files associated with a given PR +func GetPullRequestFiles(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/files repository repoGetPullRequestFiles + // --- + // summary: Get changed files for a pull request + // 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: index + // in: path + // description: index of the pull request to get + // type: integer + // format: int64 + // required: true + // - name: skip-to + // in: query + // description: skip to given file + // type: string + // - name: whitespace + // in: query + // description: whitespace behavior + // type: string + // enum: [ignore-all, ignore-change, ignore-eol, show-all] + // - 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/ChangedFileList" + // "404": + // "$ref": "#/responses/notFound" + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err := pr.LoadBaseRepo(ctx); err != nil { + ctx.InternalServerError(err) + return + } + + if err := pr.LoadHeadRepo(ctx); err != nil { + ctx.InternalServerError(err) + return + } + + baseGitRepo := ctx.Repo.GitRepo + + var prInfo *git.CompareInfo + if pr.HasMerged { + prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.MergeBase, pr.GetGitRefName(), true, false) + } else { + prInfo, err = baseGitRepo.GetCompareInfo(pr.BaseRepo.RepoPath(), pr.BaseBranch, pr.GetGitRefName(), true, false) + } + if err != nil { + ctx.ServerError("GetCompareInfo", err) + return + } + + headCommitID, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + ctx.ServerError("GetRefCommitID", err) + return + } + + startCommitID := prInfo.MergeBase + endCommitID := headCommitID + + maxLines := setting.Git.MaxGitDiffLines + + // FIXME: If there are too many files in the repo, may cause some unpredictable issues. + diff, err := gitdiff.GetDiff(ctx, baseGitRepo, + &gitdiff.DiffOptions{ + BeforeCommitID: startCommitID, + AfterCommitID: endCommitID, + SkipTo: ctx.FormString("skip-to"), + MaxLines: maxLines, + MaxLineCharacters: setting.Git.MaxGitDiffLineCharacters, + MaxFiles: -1, // GetDiff() will return all files + WhitespaceBehavior: gitdiff.GetWhitespaceFlag(ctx.FormString("whitespace")), + }) + if err != nil { + ctx.ServerError("GetDiff", err) + return + } + + listOptions := utils.GetListOptions(ctx) + + totalNumberOfFiles := diff.NumFiles + totalNumberOfPages := int(math.Ceil(float64(totalNumberOfFiles) / float64(listOptions.PageSize))) + + start, limit := listOptions.GetSkipTake() + + limit = min(limit, totalNumberOfFiles-start) + + limit = max(limit, 0) + + apiFiles := make([]*api.ChangedFile, 0, limit) + for i := start; i < start+limit; i++ { + apiFiles = append(apiFiles, convert.ToChangedFile(diff.Files[i], pr.HeadRepo, endCommitID)) + } + + ctx.SetLinkHeader(totalNumberOfFiles, listOptions.PageSize) + ctx.SetTotalCountHeader(int64(totalNumberOfFiles)) + + ctx.RespHeader().Set("X-Page", strconv.Itoa(listOptions.Page)) + ctx.RespHeader().Set("X-PerPage", strconv.Itoa(listOptions.PageSize)) + ctx.RespHeader().Set("X-PageCount", strconv.Itoa(totalNumberOfPages)) + ctx.RespHeader().Set("X-HasMore", strconv.FormatBool(listOptions.Page < totalNumberOfPages)) + ctx.AppendAccessControlExposeHeaders("X-Page", "X-PerPage", "X-PageCount", "X-HasMore") + + ctx.JSON(http.StatusOK, &apiFiles) +} diff --git a/routers/api/v1/repo/pull_review.go b/routers/api/v1/repo/pull_review.go new file mode 100644 index 0000000..8fba085 --- /dev/null +++ b/routers/api/v1/repo/pull_review.go @@ -0,0 +1,1107 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "strings" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/gitrepo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" +) + +// ListPullReviews lists all reviews of a pull request +func ListPullReviews(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews repository repoListPullReviews + // --- + // summary: List all reviews for a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - 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/PullReviewList" + // "404": + // "$ref": "#/responses/notFound" + + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err = pr.LoadIssue(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + + if err = pr.Issue.LoadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepo", err) + return + } + + opts := issues_model.FindReviewOptions{ + ListOptions: utils.GetListOptions(ctx), + IssueID: pr.IssueID, + } + + allReviews, err := issues_model.FindReviews(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + count, err := issues_model.CountReviews(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiReviews, err := convert.ToPullReviewList(ctx, allReviews, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReviewList", err) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiReviews) +} + +// GetPullReview gets a specific review of a pull request +func GetPullReview(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoGetPullReview + // --- + // summary: Get a specific review for a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "404": + // "$ref": "#/responses/notFound" + + review, _, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReview", err) + return + } + + ctx.JSON(http.StatusOK, apiReview) +} + +// GetPullReviewComments lists all comments of a pull request review +func GetPullReviewComments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments repository repoGetPullReviewComments + // --- + // summary: Get a specific review for a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullReviewCommentList" + // "404": + // "$ref": "#/responses/notFound" + + review, _, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + apiComments, err := convert.ToPullReviewCommentList(ctx, review, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReviewCommentList", err) + return + } + + ctx.JSON(http.StatusOK, apiComments) +} + +// GetPullReviewComment get a pull review comment +func GetPullReviewComment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments/{comment} repository repoGetPullReviewComment + // --- + // summary: Get a pull review comment + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // - name: comment + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullReviewComment" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + review, _, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + if err := ctx.Comment.LoadPoster(ctx); err != nil { + ctx.InternalServerError(err) + return + } + + apiComment, err := convert.ToPullReviewComment(ctx, review, ctx.Comment, ctx.Doer) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, apiComment) +} + +// CreatePullReviewComments add a new comment to a pull request review +func CreatePullReviewComment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments repository repoCreatePullReviewComment + // --- + // summary: Add a new comment to a pull request review + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreatePullReviewCommentOptions" + // responses: + // "200": + // "$ref": "#/responses/PullReviewComment" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + opts := web.GetForm(ctx).(*api.CreatePullReviewCommentOptions) + + review, pr, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + if err := pr.Issue.LoadRepo(ctx); err != nil { + ctx.InternalServerError(err) + return + } + + line := opts.NewLineNum + if opts.OldLineNum > 0 { + line = opts.OldLineNum * -1 + } + + comment, err := pull_service.CreateCodeCommentKnownReviewID(ctx, + ctx.Doer, + pr.Issue.Repo, + pr.Issue, + opts.Body, + opts.Path, + line, + review.ID, + nil, + ) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiComment, err := convert.ToPullReviewComment(ctx, review, comment, ctx.Doer) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, apiComment) +} + +// DeletePullReview delete a specific review from a pull request +func DeletePullReview(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoDeletePullReview + // --- + // summary: Delete a specific review from a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + review, _, statusSet := prepareSingleReview(ctx) + if statusSet { + return + } + + if ctx.Doer == nil { + ctx.NotFound() + return + } + if !ctx.Doer.IsAdmin && ctx.Doer.ID != review.ReviewerID { + ctx.Error(http.StatusForbidden, "only admin and user itself can delete a review", nil) + return + } + + if err := issues_model.DeleteReview(ctx, review); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteReview", fmt.Errorf("can not delete ReviewID: %d", review.ID)) + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreatePullReview create a review to a pull request +func CreatePullReview(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews repository repoCreatePullReview + // --- + // summary: Create a review to an pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreatePullReviewOptions" + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + opts := web.GetForm(ctx).(*api.CreatePullReviewOptions) + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + // determine review type + reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body, len(opts.Comments) > 0) + if isWrong { + return + } + + if err := pr.Issue.LoadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "pr.Issue.LoadRepo", err) + return + } + + // if CommitID is empty, set it as lastCommitID + if opts.CommitID == "" { + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.Issue.Repo) + if err != nil { + ctx.Error(http.StatusInternalServerError, "git.OpenRepository", err) + return + } + defer closer.Close() + + headCommitID, err := gitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRefCommitID", err) + return + } + + opts.CommitID = headCommitID + } + + // create review comments + for _, c := range opts.Comments { + line := c.NewLineNum + if c.OldLineNum > 0 { + line = c.OldLineNum * -1 + } + + if _, err := pull_service.CreateCodeComment(ctx, + ctx.Doer, + ctx.Repo.GitRepo, + pr.Issue, + line, + c.Body, + c.Path, + true, // pending review + 0, // no reply + opts.CommitID, + nil, + ); err != nil { + ctx.Error(http.StatusInternalServerError, "CreateCodeComment", err) + return + } + } + + // create review and associate all pending review comments + review, _, err := pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, opts.CommitID, nil) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SubmitReview", err) + return + } + + // convert response + apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReview", err) + return + } + ctx.JSON(http.StatusOK, apiReview) +} + +// SubmitPullReview submit a pending review to an pull request +func SubmitPullReview(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id} repository repoSubmitPullReview + // --- + // summary: Submit a pending review to an pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/SubmitPullReviewOptions" + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + opts := web.GetForm(ctx).(*api.SubmitPullReviewOptions) + review, pr, isWrong := prepareSingleReview(ctx) + if isWrong { + return + } + + if review.Type != issues_model.ReviewTypePending { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("only a pending review can be submitted")) + return + } + + // determine review type + reviewType, isWrong := preparePullReviewType(ctx, pr, opts.Event, opts.Body, len(review.Comments) > 0) + if isWrong { + return + } + + // if review stay pending return + if reviewType == issues_model.ReviewTypePending { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review stay pending")) + return + } + + headCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(pr.GetGitRefName()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GitRepo: GetRefCommitID", err) + return + } + + // create review and associate all pending review comments + review, _, err = pull_service.SubmitReview(ctx, ctx.Doer, ctx.Repo.GitRepo, pr.Issue, reviewType, opts.Body, headCommitID, nil) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SubmitReview", err) + return + } + + // convert response + apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReview", err) + return + } + ctx.JSON(http.StatusOK, apiReview) +} + +// preparePullReviewType return ReviewType and false or nil and true if an error happen +func preparePullReviewType(ctx *context.APIContext, pr *issues_model.PullRequest, event api.ReviewStateType, body string, hasComments bool) (issues_model.ReviewType, bool) { + if err := pr.LoadIssue(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return -1, true + } + + needsBody := true + hasBody := len(strings.TrimSpace(body)) > 0 + + var reviewType issues_model.ReviewType + switch event { + case api.ReviewStateApproved: + // can not approve your own PR + if pr.Issue.IsPoster(ctx.Doer.ID) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("approve your own pull is not allowed")) + return -1, true + } + reviewType = issues_model.ReviewTypeApprove + needsBody = false + + case api.ReviewStateRequestChanges: + // can not reject your own PR + if pr.Issue.IsPoster(ctx.Doer.ID) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("reject your own pull is not allowed")) + return -1, true + } + reviewType = issues_model.ReviewTypeReject + + case api.ReviewStateComment: + reviewType = issues_model.ReviewTypeComment + needsBody = false + // if there is no body we need to ensure that there are comments + if !hasBody && !hasComments { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review event %s requires a body or a comment", event)) + return -1, true + } + default: + reviewType = issues_model.ReviewTypePending + } + + // reject reviews with empty body if a body is required for this call + if needsBody && !hasBody { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("review event %s requires a body", event)) + return -1, true + } + + return reviewType, false +} + +// prepareSingleReview return review, related pull and false or nil, nil and true if an error happen +func prepareSingleReview(ctx *context.APIContext) (*issues_model.Review, *issues_model.PullRequest, bool) { + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return nil, nil, true + } + + review, err := issues_model.GetReviewByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if issues_model.IsErrReviewNotExist(err) { + ctx.NotFound("GetReviewByID", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetReviewByID", err) + } + return nil, nil, true + } + + // validate the review is for the given PR + if review.IssueID != pr.IssueID { + ctx.NotFound("ReviewNotInPR") + return nil, nil, true + } + + // make sure that the user has access to this review if it is pending + if review.Type == issues_model.ReviewTypePending && review.ReviewerID != ctx.Doer.ID && !ctx.Doer.IsAdmin { + ctx.NotFound("GetReviewByID") + return nil, nil, true + } + + if err := review.LoadAttributes(ctx); err != nil && !user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusInternalServerError, "ReviewLoadAttributes", err) + return nil, nil, true + } + + return review, pr, false +} + +// CreateReviewRequests create review requests to an pull request +func CreateReviewRequests(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers repository repoCreatePullReviewRequests + // --- + // summary: create review requests for a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/PullReviewRequestOptions" + // responses: + // "201": + // "$ref": "#/responses/PullReviewList" + // "422": + // "$ref": "#/responses/validationError" + // "404": + // "$ref": "#/responses/notFound" + + opts := web.GetForm(ctx).(*api.PullReviewRequestOptions) + apiReviewRequest(ctx, *opts, true) +} + +// DeleteReviewRequests delete review requests to an pull request +func DeleteReviewRequests(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/requested_reviewers repository repoDeletePullReviewRequests + // --- + // summary: cancel review requests for a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/PullReviewRequestOptions" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + opts := web.GetForm(ctx).(*api.PullReviewRequestOptions) + apiReviewRequest(ctx, *opts, false) +} + +func apiReviewRequest(ctx *context.APIContext, opts api.PullReviewRequestOptions, isAdd bool) { + pr, err := issues_model.GetPullRequestByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if issues_model.IsErrPullRequestNotExist(err) { + ctx.NotFound("GetPullRequestByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetPullRequestByIndex", err) + } + return + } + + if err := pr.Issue.LoadRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "pr.Issue.LoadRepo", err) + return + } + + reviewers := make([]*user_model.User, 0, len(opts.Reviewers)) + + permDoer, err := access_model.GetUserRepoPermission(ctx, pr.Issue.Repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + + for _, r := range opts.Reviewers { + var reviewer *user_model.User + if strings.Contains(r, "@") { + reviewer, err = user_model.GetUserByEmail(ctx, r) + } else { + reviewer, err = user_model.GetUserByName(ctx, r) + } + + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("UserNotExist", fmt.Sprintf("User '%s' not exist", r)) + return + } + ctx.Error(http.StatusInternalServerError, "GetUser", err) + return + } + + err = issue_service.IsValidReviewRequest(ctx, reviewer, ctx.Doer, isAdd, pr.Issue, &permDoer) + if err != nil { + if issues_model.IsErrNotValidReviewRequest(err) { + ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err) + return + } + ctx.Error(http.StatusInternalServerError, "IsValidReviewRequest", err) + return + } + + reviewers = append(reviewers, reviewer) + } + + var reviews []*issues_model.Review + if isAdd { + reviews = make([]*issues_model.Review, 0, len(reviewers)) + } + + for _, reviewer := range reviewers { + comment, err := issue_service.ReviewRequest(ctx, pr.Issue, ctx.Doer, reviewer, isAdd) + if err != nil { + if issues_model.IsErrReviewRequestOnClosedPR(err) { + ctx.Error(http.StatusForbidden, "", err) + return + } + ctx.Error(http.StatusInternalServerError, "ReviewRequest", err) + return + } + + if comment != nil && isAdd { + if err = comment.LoadReview(ctx); err != nil { + ctx.ServerError("ReviewRequest", err) + return + } + reviews = append(reviews, comment.Review) + } + } + + if ctx.Repo.Repository.Owner.IsOrganization() && len(opts.TeamReviewers) > 0 { + teamReviewers := make([]*organization.Team, 0, len(opts.TeamReviewers)) + for _, t := range opts.TeamReviewers { + var teamReviewer *organization.Team + teamReviewer, err = organization.GetTeam(ctx, ctx.Repo.Owner.ID, t) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.NotFound("TeamNotExist", fmt.Sprintf("Team '%s' not exist", t)) + return + } + ctx.Error(http.StatusInternalServerError, "ReviewRequest", err) + return + } + + err = issue_service.IsValidTeamReviewRequest(ctx, teamReviewer, ctx.Doer, isAdd, pr.Issue) + if err != nil { + if issues_model.IsErrNotValidReviewRequest(err) { + ctx.Error(http.StatusUnprocessableEntity, "NotValidReviewRequest", err) + return + } + ctx.Error(http.StatusInternalServerError, "IsValidTeamReviewRequest", err) + return + } + + teamReviewers = append(teamReviewers, teamReviewer) + } + + for _, teamReviewer := range teamReviewers { + comment, err := issue_service.TeamReviewRequest(ctx, pr.Issue, ctx.Doer, teamReviewer, isAdd) + if err != nil { + ctx.ServerError("TeamReviewRequest", err) + return + } + + if comment != nil && isAdd { + if err = comment.LoadReview(ctx); err != nil { + ctx.ServerError("ReviewRequest", err) + return + } + reviews = append(reviews, comment.Review) + } + } + } + + if isAdd { + apiReviews, err := convert.ToPullReviewList(ctx, reviews, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReviewList", err) + return + } + ctx.JSON(http.StatusCreated, apiReviews) + } else { + ctx.Status(http.StatusNoContent) + return + } +} + +// DismissPullReview dismiss a review for a pull request +func DismissPullReview(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/dismissals repository repoDismissPullReview + // --- + // summary: Dismiss a review for a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/DismissPullReviewOptions" + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + opts := web.GetForm(ctx).(*api.DismissPullReviewOptions) + dismissReview(ctx, opts.Message, true, opts.Priors) +} + +// UnDismissPullReview cancel to dismiss a review for a pull request +func UnDismissPullReview(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/undismissals repository repoUnDismissPullReview + // --- + // summary: Cancel to dismiss a review for a pull request + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PullReview" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + dismissReview(ctx, "", false, false) +} + +// DeletePullReviewComment delete a pull review comment +func DeletePullReviewComment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments/{comment} repository repoDeletePullReviewComment + // --- + // summary: Delete a pull review comment + // 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: index + // in: path + // description: index of the pull request + // type: integer + // format: int64 + // required: true + // - name: id + // in: path + // description: id of the review + // type: integer + // format: int64 + // required: true + // - name: comment + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + deleteIssueComment(ctx, issues_model.CommentTypeCode) +} + +func dismissReview(ctx *context.APIContext, msg string, isDismiss, dismissPriors bool) { + if !ctx.Repo.IsAdmin() { + ctx.Error(http.StatusForbidden, "", "Must be repo admin") + return + } + review, _, isWrong := prepareSingleReview(ctx) + if isWrong { + return + } + + if review.Type != issues_model.ReviewTypeApprove && review.Type != issues_model.ReviewTypeReject { + ctx.Error(http.StatusForbidden, "", "not need to dismiss this review because it's type is not Approve or change request") + return + } + + _, err := pull_service.DismissReview(ctx, review.ID, ctx.Repo.Repository.ID, msg, ctx.Doer, isDismiss, dismissPriors) + if err != nil { + if pull_service.IsErrDismissRequestOnClosedPR(err) { + ctx.Error(http.StatusForbidden, "", err) + return + } + ctx.Error(http.StatusInternalServerError, "pull_service.DismissReview", err) + return + } + + if review, err = issues_model.GetReviewByID(ctx, review.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "GetReviewByID", err) + return + } + + // convert response + apiReview, err := convert.ToPullReview(ctx, review, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convertToPullReview", err) + return + } + ctx.JSON(http.StatusOK, apiReview) +} diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go new file mode 100644 index 0000000..5ea4dc8 --- /dev/null +++ b/routers/api/v1/repo/release.go @@ -0,0 +1,424 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + release_service "code.gitea.io/gitea/services/release" +) + +// GetRelease get a single release of a repository +func GetRelease(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/releases/{id} repository repoGetRelease + // --- + // summary: Get a release + // 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: id + // in: path + // description: id of the release to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Release" + // "404": + // "$ref": "#/responses/notFound" + + id := ctx.ParamsInt64(":id") + release, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err) + return + } + if err != nil && repo_model.IsErrReleaseNotExist(err) || release.IsTag { + ctx.NotFound() + return + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)) +} + +// GetLatestRelease gets the most recent non-prerelease, non-draft release of a repository, sorted by created_at +func GetLatestRelease(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/releases/latest repository repoGetLatestRelease + // --- + // summary: Gets the most recent non-prerelease, non-draft release of a repository, sorted by created_at + // 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 + // responses: + // "200": + // "$ref": "#/responses/Release" + // "404": + // "$ref": "#/responses/notFound" + release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetLatestRelease", err) + return + } + if err != nil && repo_model.IsErrReleaseNotExist(err) || + release.IsTag || release.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return + } + + if err := release.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)) +} + +// ListReleases list a repository's releases +func ListReleases(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/releases repository repoListReleases + // --- + // summary: List a repo's releases + // 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: draft + // in: query + // description: filter (exclude / include) drafts, if you dont have repo write access none will show + // type: boolean + // - name: pre-release + // in: query + // description: filter (exclude / include) pre-releases + // type: boolean + // - 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/ReleaseList" + // "404": + // "$ref": "#/responses/notFound" + listOptions := utils.GetListOptions(ctx) + + opts := repo_model.FindReleasesOptions{ + ListOptions: listOptions, + IncludeDrafts: ctx.Repo.AccessMode >= perm.AccessModeWrite || ctx.Repo.UnitAccessMode(unit.TypeReleases) >= perm.AccessModeWrite, + IncludeTags: false, + IsDraft: ctx.FormOptionalBool("draft"), + IsPreRelease: ctx.FormOptionalBool("pre-release"), + RepoID: ctx.Repo.Repository.ID, + } + + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetReleasesByRepoID", err) + return + } + rels := make([]*api.Release, len(releases)) + for i, release := range releases { + if err := release.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + rels[i] = convert.ToAPIRelease(ctx, ctx.Repo.Repository, release) + } + + filteredCount, err := db.Count[repo_model.Release](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetLinkHeader(int(filteredCount), listOptions.PageSize) + ctx.SetTotalCountHeader(filteredCount) + ctx.JSON(http.StatusOK, rels) +} + +// CreateRelease create a release +func CreateRelease(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/releases repository repoCreateRelease + // --- + // summary: Create a release + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateReleaseOption" + // responses: + // "201": + // "$ref": "#/responses/Release" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // "$ref": "#/responses/error" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateReleaseOption) + if ctx.Repo.Repository.IsEmpty { + ctx.Error(http.StatusUnprocessableEntity, "RepoIsEmpty", fmt.Errorf("repo is empty")) + return + } + rel, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, form.TagName) + if err != nil { + if !repo_model.IsErrReleaseNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetRelease", err) + return + } + // If target is not provided use default branch + if len(form.Target) == 0 { + form.Target = ctx.Repo.Repository.DefaultBranch + } + rel = &repo_model.Release{ + RepoID: ctx.Repo.Repository.ID, + PublisherID: ctx.Doer.ID, + Publisher: ctx.Doer, + TagName: form.TagName, + Target: form.Target, + Title: form.Title, + Note: form.Note, + IsDraft: form.IsDraft, + IsPrerelease: form.IsPrerelease, + HideArchiveLinks: form.HideArchiveLinks, + IsTag: false, + Repo: ctx.Repo.Repository, + } + if err := release_service.CreateRelease(ctx.Repo.GitRepo, rel, "", nil); err != nil { + if repo_model.IsErrReleaseAlreadyExist(err) { + ctx.Error(http.StatusConflict, "ReleaseAlreadyExist", err) + } else if models.IsErrProtectedTagName(err) { + ctx.Error(http.StatusUnprocessableEntity, "ProtectedTagName", err) + } else if git.IsErrNotExist(err) { + ctx.Error(http.StatusNotFound, "ErrNotExist", fmt.Errorf("target \"%v\" not found: %w", rel.Target, err)) + } else { + ctx.Error(http.StatusInternalServerError, "CreateRelease", err) + } + return + } + } else { + if !rel.IsTag { + ctx.Error(http.StatusConflict, "GetRelease", "Release is has no Tag") + return + } + + rel.Title = form.Title + rel.Note = form.Note + rel.IsDraft = form.IsDraft + rel.IsPrerelease = form.IsPrerelease + rel.HideArchiveLinks = form.HideArchiveLinks + rel.PublisherID = ctx.Doer.ID + rel.IsTag = false + rel.Repo = ctx.Repo.Repository + rel.Publisher = ctx.Doer + rel.Target = form.Target + + if err = release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, true, nil); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) + return + } + } + ctx.JSON(http.StatusCreated, convert.ToAPIRelease(ctx, ctx.Repo.Repository, rel)) +} + +// EditRelease edit a release +func EditRelease(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/releases/{id} repository repoEditRelease + // --- + // summary: Update a release + // consumes: + // - application/json + // 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: id + // in: path + // description: id of the release to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditReleaseOption" + // responses: + // "200": + // "$ref": "#/responses/Release" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditReleaseOption) + id := ctx.ParamsInt64(":id") + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err) + return + } + if err != nil && repo_model.IsErrReleaseNotExist(err) || rel.IsTag { + ctx.NotFound() + return + } + + if len(form.TagName) > 0 { + rel.TagName = form.TagName + } + if len(form.Target) > 0 { + rel.Target = form.Target + } + if len(form.Title) > 0 { + rel.Title = form.Title + } + if len(form.Note) > 0 { + rel.Note = form.Note + } + if form.IsDraft != nil { + rel.IsDraft = *form.IsDraft + } + if form.IsPrerelease != nil { + rel.IsPrerelease = *form.IsPrerelease + } + if form.HideArchiveLinks != nil { + rel.HideArchiveLinks = *form.HideArchiveLinks + } + if err := release_service.UpdateRelease(ctx, ctx.Doer, ctx.Repo.GitRepo, rel, false, nil); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateRelease", err) + return + } + + // reload data from database + rel, err = repo_model.GetReleaseByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + return + } + if err := rel.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, rel)) +} + +// DeleteRelease delete a release from a repository +func DeleteRelease(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/releases/{id} repository repoDeleteRelease + // --- + // summary: Delete a release + // 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: id + // in: path + // description: id of the release to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + id := ctx.ParamsInt64(":id") + rel, err := repo_model.GetReleaseForRepoByID(ctx, ctx.Repo.Repository.ID, id) + if err != nil && !repo_model.IsErrReleaseNotExist(err) { + ctx.Error(http.StatusInternalServerError, "GetReleaseForRepoByID", err) + return + } + if err != nil && repo_model.IsErrReleaseNotExist(err) || rel.IsTag { + ctx.NotFound() + return + } + if err := release_service.DeleteReleaseByID(ctx, ctx.Repo.Repository, rel, ctx.Doer, false); err != nil { + if models.IsErrProtectedTagName(err) { + ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag") + return + } + ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/release_attachment.go b/routers/api/v1/repo/release_attachment.go new file mode 100644 index 0000000..d569f6e --- /dev/null +++ b/routers/api/v1/repo/release_attachment.go @@ -0,0 +1,467 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "io" + "mime/multipart" + "net/http" + "net/url" + "path" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/attachment" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + "code.gitea.io/gitea/services/convert" +) + +func checkReleaseMatchRepo(ctx *context.APIContext, releaseID int64) bool { + release, err := repo_model.GetReleaseByID(ctx, releaseID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound() + return false + } + ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + return false + } + if release.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return false + } + return true +} + +// GetReleaseAttachment gets a single attachment of the release +func GetReleaseAttachment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} repository repoGetReleaseAttachment + // --- + // summary: Get a release attachment + // 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: id + // in: path + // description: id of the release + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/notFound" + + releaseID := ctx.ParamsInt64(":id") + if !checkReleaseMatchRepo(ctx, releaseID) { + return + } + + attachID := ctx.ParamsInt64(":attachment_id") + attach, err := repo_model.GetAttachmentByID(ctx, attachID) + if err != nil { + if repo_model.IsErrAttachmentNotExist(err) { + ctx.NotFound() + return + } + ctx.Error(http.StatusInternalServerError, "GetAttachmentByID", err) + return + } + if attach.ReleaseID != releaseID { + log.Info("User requested attachment is not in release, release_id %v, attachment_id: %v", releaseID, attachID) + ctx.NotFound() + return + } + // FIXME Should prove the existence of the given repo, but results in unnecessary database requests + ctx.JSON(http.StatusOK, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) +} + +// ListReleaseAttachments lists all attachments of the release +func ListReleaseAttachments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/releases/{id}/assets repository repoListReleaseAttachments + // --- + // summary: List release's attachments + // 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: id + // in: path + // description: id of the release + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/AttachmentList" + // "404": + // "$ref": "#/responses/notFound" + + releaseID := ctx.ParamsInt64(":id") + release, err := repo_model.GetReleaseByID(ctx, releaseID) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound() + return + } + ctx.Error(http.StatusInternalServerError, "GetReleaseByID", err) + return + } + if release.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound() + return + } + if err := release.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release).Attachments) +} + +// CreateReleaseAttachment creates an attachment and saves the given file +func CreateReleaseAttachment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/releases/{id}/assets repository repoCreateReleaseAttachment + // --- + // summary: Create a release attachment + // produces: + // - application/json + // consumes: + // - multipart/form-data + // - application/octet-stream + // 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: id + // in: path + // description: id of the release + // type: integer + // format: int64 + // required: true + // - name: name + // in: query + // description: name of the attachment + // type: string + // required: false + // # There is no good way to specify "either 'attachment' or 'external_url' is required" with OpenAPI + // # https://github.com/OAI/OpenAPI-Specification/issues/256 + // - name: attachment + // in: formData + // description: attachment to upload (this parameter is incompatible with `external_url`) + // type: file + // required: false + // - name: external_url + // in: formData + // description: url to external asset (this parameter is incompatible with `attachment`) + // type: string + // required: false + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + + // Check if attachments are enabled + if !setting.Attachment.Enabled { + ctx.NotFound("Attachment is not enabled") + return + } + + // Check if release exists an load release + releaseID := ctx.ParamsInt64(":id") + if !checkReleaseMatchRepo(ctx, releaseID) { + return + } + + // Get uploaded file from request + var isForm, hasAttachmentFile, hasExternalURL bool + externalURL := ctx.FormString("external_url") + hasExternalURL = externalURL != "" + filename := ctx.FormString("name") + isForm = strings.HasPrefix(strings.ToLower(ctx.Req.Header.Get("Content-Type")), "multipart/form-data") + + if isForm { + _, _, err := ctx.Req.FormFile("attachment") + hasAttachmentFile = err == nil + } else { + hasAttachmentFile = ctx.Req.Body != nil + } + + if hasAttachmentFile && hasExternalURL { + ctx.Error(http.StatusBadRequest, "DuplicateAttachment", "'attachment' and 'external_url' are mutually exclusive") + } else if hasAttachmentFile { + var content io.ReadCloser + var size int64 = -1 + + if isForm { + var header *multipart.FileHeader + content, header, _ = ctx.Req.FormFile("attachment") + size = header.Size + defer content.Close() + if filename == "" { + filename = header.Filename + } + } else { + content = ctx.Req.Body + defer content.Close() + } + + if filename == "" { + ctx.Error(http.StatusBadRequest, "MissingName", "Missing 'name' parameter") + return + } + + // Create a new attachment and save the file + attach, err := attachment.UploadAttachment(ctx, content, setting.Repository.Release.AllowedTypes, size, &repo_model.Attachment{ + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + ReleaseID: releaseID, + }) + if err != nil { + if upload.IsErrFileTypeForbidden(err) { + ctx.Error(http.StatusBadRequest, "DetectContentType", err) + return + } + ctx.Error(http.StatusInternalServerError, "NewAttachment", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) + } else if hasExternalURL { + url, err := url.Parse(externalURL) + if err != nil { + ctx.Error(http.StatusBadRequest, "InvalidExternalURL", err) + return + } + + if filename == "" { + filename = path.Base(url.Path) + + if filename == "." { + // Url path is empty + filename = url.Host + } + } + + attach, err := attachment.NewExternalAttachment(ctx, &repo_model.Attachment{ + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + ReleaseID: releaseID, + ExternalURL: url.String(), + }) + if err != nil { + if repo_model.IsErrInvalidExternalURL(err) { + ctx.Error(http.StatusBadRequest, "NewExternalAttachment", err) + } else { + ctx.Error(http.StatusInternalServerError, "NewExternalAttachment", err) + } + return + } + + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) + } else { + ctx.Error(http.StatusBadRequest, "MissingAttachment", "One of 'attachment' or 'external_url' is required") + } +} + +// EditReleaseAttachment updates the given attachment +func EditReleaseAttachment(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} repository repoEditReleaseAttachment + // --- + // summary: Edit a release attachment + // produces: + // - application/json + // consumes: + // - 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: id + // in: path + // description: id of the release + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditAttachmentOptions" + // responses: + // "201": + // "$ref": "#/responses/Attachment" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + + form := web.GetForm(ctx).(*api.EditAttachmentOptions) + + // Check if release exists an load release + releaseID := ctx.ParamsInt64(":id") + if !checkReleaseMatchRepo(ctx, releaseID) { + return + } + + attachID := ctx.ParamsInt64(":attachment_id") + attach, err := repo_model.GetAttachmentByID(ctx, attachID) + if err != nil { + if repo_model.IsErrAttachmentNotExist(err) { + ctx.NotFound() + return + } + ctx.Error(http.StatusInternalServerError, "GetAttachmentByID", err) + return + } + if attach.ReleaseID != releaseID { + log.Info("User requested attachment is not in release, release_id %v, attachment_id: %v", releaseID, attachID) + ctx.NotFound() + return + } + // FIXME Should prove the existence of the given repo, but results in unnecessary database requests + if form.Name != "" { + attach.Name = form.Name + } + + if form.DownloadURL != "" { + if attach.ExternalURL == "" { + ctx.Error(http.StatusBadRequest, "EditAttachment", "existing attachment is not external") + return + } + attach.ExternalURL = form.DownloadURL + } + + if err := repo_model.UpdateAttachment(ctx, attach); err != nil { + if repo_model.IsErrInvalidExternalURL(err) { + ctx.Error(http.StatusBadRequest, "UpdateAttachment", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateAttachment", err) + } + return + } + ctx.JSON(http.StatusCreated, convert.ToAPIAttachment(ctx.Repo.Repository, attach)) +} + +// DeleteReleaseAttachment delete a given attachment +func DeleteReleaseAttachment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/releases/{id}/assets/{attachment_id} repository repoDeleteReleaseAttachment + // --- + // summary: Delete a release attachment + // 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: id + // in: path + // description: id of the release + // type: integer + // format: int64 + // required: true + // - name: attachment_id + // in: path + // description: id of the attachment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + // Check if release exists an load release + releaseID := ctx.ParamsInt64(":id") + if !checkReleaseMatchRepo(ctx, releaseID) { + return + } + + attachID := ctx.ParamsInt64(":attachment_id") + attach, err := repo_model.GetAttachmentByID(ctx, attachID) + if err != nil { + if repo_model.IsErrAttachmentNotExist(err) { + ctx.NotFound() + return + } + ctx.Error(http.StatusInternalServerError, "GetAttachmentByID", err) + return + } + if attach.ReleaseID != releaseID { + log.Info("User requested attachment is not in release, release_id %v, attachment_id: %v", releaseID, attachID) + ctx.NotFound() + return + } + // FIXME Should prove the existence of the given repo, but results in unnecessary database requests + + if err := repo_model.DeleteAttachment(ctx, attach, true); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAttachment", err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/release_tags.go b/routers/api/v1/repo/release_tags.go new file mode 100644 index 0000000..f845fad --- /dev/null +++ b/routers/api/v1/repo/release_tags.go @@ -0,0 +1,125 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + releaseservice "code.gitea.io/gitea/services/release" +) + +// GetReleaseByTag get a single release of a repository by tag name +func GetReleaseByTag(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/releases/tags/{tag} repository repoGetReleaseByTag + // --- + // summary: Get a release by tag name + // 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: tag + // in: path + // description: tag name of the release to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Release" + // "404": + // "$ref": "#/responses/notFound" + + tag := ctx.Params(":tag") + + release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tag) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound() + return + } + ctx.Error(http.StatusInternalServerError, "GetRelease", err) + return + } + + if release.IsTag { + ctx.NotFound() + return + } + + if err = release.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttributes", err) + return + } + ctx.JSON(http.StatusOK, convert.ToAPIRelease(ctx, ctx.Repo.Repository, release)) +} + +// DeleteReleaseByTag delete a release from a repository by tag name +func DeleteReleaseByTag(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/releases/tags/{tag} repository repoDeleteReleaseByTag + // --- + // summary: Delete a release by tag name + // 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: tag + // in: path + // description: tag name of the release to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + tag := ctx.Params(":tag") + + release, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tag) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound() + return + } + ctx.Error(http.StatusInternalServerError, "GetRelease", err) + return + } + + if release.IsTag { + ctx.NotFound() + return + } + + if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, release, ctx.Doer, false); err != nil { + if models.IsErrProtectedTagName(err) { + ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag") + return + } + ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go new file mode 100644 index 0000000..e25593c --- /dev/null +++ b/routers/api/v1/repo/repo.go @@ -0,0 +1,1334 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + "slices" + "strings" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + activities_model "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/label" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/validation" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/issue" + repo_service "code.gitea.io/gitea/services/repository" + wiki_service "code.gitea.io/gitea/services/wiki" +) + +// Search repositories via options +func Search(ctx *context.APIContext) { + // swagger:operation GET /repos/search repository repoSearch + // --- + // summary: Search for repositories + // produces: + // - application/json + // parameters: + // - name: q + // in: query + // description: keyword + // type: string + // - name: topic + // in: query + // description: Limit search to repositories with keyword as topic + // type: boolean + // - name: includeDesc + // in: query + // description: include search of keyword within repository description + // type: boolean + // - name: uid + // in: query + // description: search only for repos that the user with the given id owns or contributes to + // type: integer + // format: int64 + // - name: priority_owner_id + // in: query + // description: repo owner to prioritize in the results + // type: integer + // format: int64 + // - name: team_id + // in: query + // description: search only for repos that belong to the given team id + // type: integer + // format: int64 + // - name: starredBy + // in: query + // description: search only for repos that the user with the given id has starred + // type: integer + // format: int64 + // - name: private + // in: query + // description: include private repositories this user has access to (defaults to true) + // type: boolean + // - name: is_private + // in: query + // description: show only pubic, private or all repositories (defaults to all) + // type: boolean + // - name: template + // in: query + // description: include template repositories this user has access to (defaults to true) + // type: boolean + // - name: archived + // in: query + // description: show only archived, non-archived or all repositories (defaults to all) + // type: boolean + // - name: mode + // in: query + // description: type of repository to search for. Supported values are + // "fork", "source", "mirror" and "collaborative" + // type: string + // - name: exclusive + // in: query + // description: if `uid` is given, search only for repos that the user owns + // type: boolean + // - name: sort + // in: query + // description: sort repos by attribute. Supported values are + // "alpha", "created", "updated", "size", "git_size", "lfs_size", "stars", "forks" and "id". + // Default is "alpha" + // type: string + // - name: order + // in: query + // description: sort order, either "asc" (ascending) or "desc" (descending). + // Default is "asc", ignored if "sort" is not specified. + // type: string + // - 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/SearchResults" + // "422": + // "$ref": "#/responses/validationError" + + private := ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")) + if ctx.PublicOnly { + private = false + } + + opts := &repo_model.SearchRepoOptions{ + ListOptions: utils.GetListOptions(ctx), + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + OwnerID: ctx.FormInt64("uid"), + PriorityOwnerID: ctx.FormInt64("priority_owner_id"), + TeamID: ctx.FormInt64("team_id"), + TopicOnly: ctx.FormBool("topic"), + Collaborate: optional.None[bool](), + Private: private, + Template: optional.None[bool](), + StarredByID: ctx.FormInt64("starredBy"), + IncludeDescription: ctx.FormBool("includeDesc"), + } + + if ctx.FormString("template") != "" { + opts.Template = optional.Some(ctx.FormBool("template")) + } + + if ctx.FormBool("exclusive") { + opts.Collaborate = optional.Some(false) + } + + mode := ctx.FormString("mode") + switch mode { + case "source": + opts.Fork = optional.Some(false) + opts.Mirror = optional.Some(false) + case "fork": + opts.Fork = optional.Some(true) + case "mirror": + opts.Mirror = optional.Some(true) + case "collaborative": + opts.Mirror = optional.Some(false) + opts.Collaborate = optional.Some(true) + case "": + default: + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid search mode: \"%s\"", mode)) + return + } + + if ctx.FormString("archived") != "" { + opts.Archived = optional.Some(ctx.FormBool("archived")) + } + + if ctx.FormString("is_private") != "" { + opts.IsPrivate = optional.Some(ctx.FormBool("is_private")) + } + + sortMode := ctx.FormString("sort") + if len(sortMode) > 0 { + sortOrder := ctx.FormString("order") + if len(sortOrder) == 0 { + sortOrder = "asc" + } + if searchModeMap, ok := repo_model.OrderByMap[sortOrder]; ok { + if orderBy, ok := searchModeMap[sortMode]; ok { + opts.OrderBy = orderBy + } else { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid sort mode: \"%s\"", sortMode)) + return + } + } else { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("Invalid sort order: \"%s\"", sortOrder)) + return + } + } + + var err error + repos, count, err := repo_model.SearchRepository(ctx, opts) + if err != nil { + ctx.JSON(http.StatusInternalServerError, api.SearchError{ + OK: false, + Error: err.Error(), + }) + return + } + + results := make([]*api.Repository, len(repos)) + for i, repo := range repos { + if err = repo.LoadOwner(ctx); err != nil { + ctx.JSON(http.StatusInternalServerError, api.SearchError{ + OK: false, + Error: err.Error(), + }) + return + } + permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.JSON(http.StatusInternalServerError, api.SearchError{ + OK: false, + Error: err.Error(), + }) + } + results[i] = convert.ToRepo(ctx, repo, permission) + } + ctx.SetLinkHeader(int(count), opts.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, api.SearchResults{ + OK: true, + Data: results, + }) +} + +// CreateUserRepo create a repository for a user +func CreateUserRepo(ctx *context.APIContext, owner *user_model.User, opt api.CreateRepoOption) { + if opt.AutoInit && opt.Readme == "" { + opt.Readme = "Default" + } + + // If the readme template does not exist, a 400 will be returned. + if opt.AutoInit && len(opt.Readme) > 0 && !slices.Contains(repo_module.Readmes, opt.Readme) { + ctx.Error(http.StatusBadRequest, "", fmt.Errorf("readme template does not exist, available templates: %v", repo_module.Readmes)) + return + } + + repo, err := repo_service.CreateRepository(ctx, ctx.Doer, owner, repo_service.CreateRepoOptions{ + Name: opt.Name, + Description: opt.Description, + IssueLabels: opt.IssueLabels, + Gitignores: opt.Gitignores, + License: opt.License, + Readme: opt.Readme, + IsPrivate: opt.Private || setting.Repository.ForcePrivate, + AutoInit: opt.AutoInit, + DefaultBranch: opt.DefaultBranch, + TrustModel: repo_model.ToTrustModel(opt.TrustModel), + IsTemplate: opt.Template, + ObjectFormatName: opt.ObjectFormatName, + }) + if err != nil { + if repo_model.IsErrRepoAlreadyExist(err) { + ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.") + } else if db.IsErrNameReserved(err) || + db.IsErrNamePatternNotAllowed(err) || + label.IsErrTemplateLoad(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateRepository", err) + } + return + } + + // reload repo from db to get a real state after creation + repo, err = repo_model.GetRepositoryByID(ctx, repo.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByID", err) + } + + ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner})) +} + +// Create one repository of mine +func Create(ctx *context.APIContext) { + // swagger:operation POST /user/repos repository user createCurrentUserRepo + // --- + // summary: Create a repository + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateRepoOption" + // responses: + // "201": + // "$ref": "#/responses/Repository" + // "400": + // "$ref": "#/responses/error" + // "409": + // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + opt := web.GetForm(ctx).(*api.CreateRepoOption) + if ctx.Doer.IsOrganization() { + // Shouldn't reach this condition, but just in case. + ctx.Error(http.StatusUnprocessableEntity, "", "not allowed creating repository for organization") + return + } + CreateUserRepo(ctx, ctx.Doer, *opt) +} + +// Generate Create a repository using a template +func Generate(ctx *context.APIContext) { + // swagger:operation POST /repos/{template_owner}/{template_repo}/generate repository generateRepo + // --- + // summary: Create a repository using a template + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: template_owner + // in: path + // description: name of the template repository owner + // type: string + // required: true + // - name: template_repo + // in: path + // description: name of the template repository + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/GenerateRepoOption" + // responses: + // "201": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "409": + // description: The repository with the same name already exists. + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.GenerateRepoOption) + + if !ctx.Repo.Repository.IsTemplate { + ctx.Error(http.StatusUnprocessableEntity, "", "this is not a template repo") + return + } + + if ctx.Doer.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", "not allowed creating repository for organization") + return + } + + opts := repo_service.GenerateRepoOptions{ + Name: form.Name, + DefaultBranch: form.DefaultBranch, + Description: form.Description, + Private: form.Private || setting.Repository.ForcePrivate, + GitContent: form.GitContent, + Topics: form.Topics, + GitHooks: form.GitHooks, + Webhooks: form.Webhooks, + Avatar: form.Avatar, + IssueLabels: form.Labels, + ProtectedBranch: form.ProtectedBranch, + } + + if !opts.IsValid() { + ctx.Error(http.StatusUnprocessableEntity, "", "must select at least one template item") + return + } + + ctxUser := ctx.Doer + var err error + if form.Owner != ctxUser.Name { + ctxUser, err = user_model.GetUserByName(ctx, form.Owner) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.JSON(http.StatusNotFound, map[string]any{ + "error": "request owner `" + form.Owner + "` does not exist", + }) + return + } + + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + return + } + + if !ctx.Doer.IsAdmin && !ctxUser.IsOrganization() { + ctx.Error(http.StatusForbidden, "", "Only admin can generate repository for other user.") + return + } + + if !ctx.Doer.IsAdmin { + canCreate, err := organization.OrgFromUser(ctxUser).CanCreateOrgRepo(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("CanCreateOrgRepo", err) + return + } else if !canCreate { + ctx.Error(http.StatusForbidden, "", "Given user is not allowed to create repository in organization.") + return + } + } + } + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, ctxUser.ID, ctxUser.Name) { + return + } + + repo, err := repo_service.GenerateRepository(ctx, ctx.Doer, ctxUser, ctx.Repo.Repository, opts) + if err != nil { + if repo_model.IsErrRepoAlreadyExist(err) { + ctx.Error(http.StatusConflict, "", "The repository with the same name already exists.") + } else if db.IsErrNameReserved(err) || + db.IsErrNamePatternNotAllowed(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateRepository", err) + } + return + } + log.Trace("Repository generated [%d]: %s/%s", repo.ID, ctxUser.Name, repo.Name) + + ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeOwner})) +} + +// CreateOrgRepoDeprecated create one repository of the organization +func CreateOrgRepoDeprecated(ctx *context.APIContext) { + // swagger:operation POST /org/{org}/repos organization createOrgRepoDeprecated + // --- + // summary: Create a repository in an organization + // deprecated: true + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateRepoOption" + // responses: + // "201": + // "$ref": "#/responses/Repository" + // "422": + // "$ref": "#/responses/validationError" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + CreateOrgRepo(ctx) +} + +// CreateOrgRepo create one repository of the organization +func CreateOrgRepo(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/repos organization createOrgRepo + // --- + // summary: Create a repository in an organization + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateRepoOption" + // responses: + // "201": + // "$ref": "#/responses/Repository" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + // "403": + // "$ref": "#/responses/forbidden" + opt := web.GetForm(ctx).(*api.CreateRepoOption) + org, err := organization.GetOrgByName(ctx, ctx.Params(":org")) + if err != nil { + if organization.IsErrOrgNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetOrgByName", err) + } + return + } + + if !organization.HasOrgOrUserVisible(ctx, org.AsUser(), ctx.Doer) { + ctx.NotFound("HasOrgOrUserVisible", nil) + return + } + + if !ctx.Doer.IsAdmin { + canCreate, err := org.CanCreateOrgRepo(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CanCreateOrgRepo", err) + return + } else if !canCreate { + ctx.Error(http.StatusForbidden, "", "Given user is not allowed to create repository in organization.") + return + } + } + CreateUserRepo(ctx, org.AsUser(), *opt) +} + +// Get one repository +func Get(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo} repository repoGet + // --- + // summary: Get a repository + // 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 + // responses: + // "200": + // "$ref": "#/responses/Repository" + // "404": + // "$ref": "#/responses/notFound" + + if err := ctx.Repo.Repository.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "Repository.LoadAttributes", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToRepo(ctx, ctx.Repo.Repository, ctx.Repo.Permission)) +} + +// GetByID returns a single Repository +func GetByID(ctx *context.APIContext) { + // swagger:operation GET /repositories/{id} repository repoGetByID + // --- + // summary: Get a repository by id + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the repo to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Repository" + // "404": + // "$ref": "#/responses/notFound" + + repo, err := repo_model.GetRepositoryByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetRepositoryByID", err) + } + return + } + + permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } else if !permission.HasAccess() { + ctx.NotFound() + return + } + ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, permission)) +} + +// Edit edit repository properties +func Edit(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo} repository repoEdit + // --- + // summary: Edit a repository's properties. Only fields that are set will be changed. + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to edit + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to edit + // type: string + // required: true + // - name: body + // in: body + // description: "Properties of a repo that you can edit" + // schema: + // "$ref": "#/definitions/EditRepoOption" + // responses: + // "200": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + opts := *web.GetForm(ctx).(*api.EditRepoOption) + + if err := updateBasicProperties(ctx, opts); err != nil { + return + } + + if err := updateRepoUnits(ctx, opts); err != nil { + return + } + + if opts.Archived != nil { + if err := updateRepoArchivedState(ctx, opts); err != nil { + return + } + } + + if opts.MirrorInterval != nil || opts.EnablePrune != nil { + if err := updateMirror(ctx, opts); err != nil { + return + } + } + + repo, err := repo_model.GetRepositoryByID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, convert.ToRepo(ctx, repo, ctx.Repo.Permission)) +} + +// updateBasicProperties updates the basic properties of a repo: Name, Description, Website and Visibility +func updateBasicProperties(ctx *context.APIContext, opts api.EditRepoOption) error { + owner := ctx.Repo.Owner + repo := ctx.Repo.Repository + newRepoName := repo.Name + if opts.Name != nil { + newRepoName = *opts.Name + } + // Check if repository name has been changed and not just a case change + if repo.LowerName != strings.ToLower(newRepoName) { + if err := repo_service.ChangeRepositoryName(ctx, ctx.Doer, repo, newRepoName); err != nil { + switch { + case repo_model.IsErrRepoAlreadyExist(err): + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("repo name is already taken [name: %s]", newRepoName), err) + case db.IsErrNameReserved(err): + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("repo name is reserved [name: %s]", newRepoName), err) + case db.IsErrNamePatternNotAllowed(err): + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("repo name's pattern is not allowed [name: %s, pattern: %s]", newRepoName, err.(db.ErrNamePatternNotAllowed).Pattern), err) + default: + ctx.Error(http.StatusUnprocessableEntity, "ChangeRepositoryName", err) + } + return err + } + + log.Trace("Repository name changed: %s/%s -> %s", ctx.Repo.Owner.Name, repo.Name, newRepoName) + } + // Update the name in the repo object for the response + repo.Name = newRepoName + repo.LowerName = strings.ToLower(newRepoName) + + if opts.Description != nil { + repo.Description = *opts.Description + } + + if opts.Website != nil { + repo.Website = *opts.Website + } + + visibilityChanged := false + if opts.Private != nil { + // Visibility of forked repository is forced sync with base repository. + if repo.IsFork { + if err := repo.GetBaseRepo(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "Unable to load base repository", err) + return err + } + *opts.Private = repo.BaseRepo.IsPrivate + } + + visibilityChanged = repo.IsPrivate != *opts.Private + // when ForcePrivate enabled, you could change public repo to private, but only admin users can change private to public + if visibilityChanged && setting.Repository.ForcePrivate && !*opts.Private && !ctx.Doer.IsAdmin { + err := fmt.Errorf("cannot change private repository to public") + ctx.Error(http.StatusUnprocessableEntity, "Force Private enabled", err) + return err + } + + repo.IsPrivate = *opts.Private + } + + if opts.Template != nil { + repo.IsTemplate = *opts.Template + } + + if ctx.Repo.GitRepo == nil && !repo.IsEmpty { + var err error + ctx.Repo.GitRepo, err = gitrepo.OpenRepository(ctx, repo) + if err != nil { + ctx.Error(http.StatusInternalServerError, "Unable to OpenRepository", err) + return err + } + defer ctx.Repo.GitRepo.Close() + } + + // Default branch only updated if changed and exist or the repository is empty + if opts.DefaultBranch != nil && repo.DefaultBranch != *opts.DefaultBranch && (repo.IsEmpty || ctx.Repo.GitRepo.IsBranchExist(*opts.DefaultBranch)) { + if !repo.IsEmpty { + if err := gitrepo.SetDefaultBranch(ctx, ctx.Repo.Repository, *opts.DefaultBranch); err != nil { + if !git.IsErrUnsupportedVersion(err) { + ctx.Error(http.StatusInternalServerError, "SetDefaultBranch", err) + return err + } + } + } + repo.DefaultBranch = *opts.DefaultBranch + } + + // Wiki branch is updated if changed + if opts.WikiBranch != nil && repo.WikiBranch != *opts.WikiBranch { + if err := wiki_service.NormalizeWikiBranch(ctx, repo, *opts.WikiBranch); err != nil { + ctx.Error(http.StatusInternalServerError, "NormalizeWikiBranch", err) + return err + } + // While NormalizeWikiBranch updates the db, we need to update *this* + // instance of `repo`, so that the `UpdateRepository` below will not + // reset the branch back. + repo.WikiBranch = *opts.WikiBranch + } + + if err := repo_service.UpdateRepository(ctx, repo, visibilityChanged); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateRepository", err) + return err + } + + log.Trace("Repository basic settings updated: %s/%s", owner.Name, repo.Name) + return nil +} + +// updateRepoUnits updates repo units: Issue settings, Wiki settings, PR settings +func updateRepoUnits(ctx *context.APIContext, opts api.EditRepoOption) error { + owner := ctx.Repo.Owner + repo := ctx.Repo.Repository + + var units []repo_model.RepoUnit + var deleteUnitTypes []unit_model.Type + + currHasIssues := repo.UnitEnabled(ctx, unit_model.TypeIssues) + newHasIssues := currHasIssues + if opts.HasIssues != nil { + newHasIssues = *opts.HasIssues + } + if currHasIssues || newHasIssues { + if newHasIssues && opts.ExternalTracker != nil && !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + // Check that values are valid + if !validation.IsValidExternalURL(opts.ExternalTracker.ExternalTrackerURL) { + err := fmt.Errorf("External tracker URL not valid") + ctx.Error(http.StatusUnprocessableEntity, "Invalid external tracker URL", err) + return err + } + if len(opts.ExternalTracker.ExternalTrackerFormat) != 0 && !validation.IsValidExternalTrackerURLFormat(opts.ExternalTracker.ExternalTrackerFormat) { + err := fmt.Errorf("External tracker URL format not valid") + ctx.Error(http.StatusUnprocessableEntity, "Invalid external tracker URL format", err) + return err + } + + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeExternalTracker, + Config: &repo_model.ExternalTrackerConfig{ + ExternalTrackerURL: opts.ExternalTracker.ExternalTrackerURL, + ExternalTrackerFormat: opts.ExternalTracker.ExternalTrackerFormat, + ExternalTrackerStyle: opts.ExternalTracker.ExternalTrackerStyle, + ExternalTrackerRegexpPattern: opts.ExternalTracker.ExternalTrackerRegexpPattern, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } else if newHasIssues && opts.ExternalTracker == nil && !unit_model.TypeIssues.UnitGlobalDisabled() { + // Default to built-in tracker + var config *repo_model.IssuesConfig + + if opts.InternalTracker != nil { + config = &repo_model.IssuesConfig{ + EnableTimetracker: opts.InternalTracker.EnableTimeTracker, + AllowOnlyContributorsToTrackTime: opts.InternalTracker.AllowOnlyContributorsToTrackTime, + EnableDependencies: opts.InternalTracker.EnableIssueDependencies, + } + } else if unit, err := repo.GetUnit(ctx, unit_model.TypeIssues); err != nil { + // Unit type doesn't exist so we make a new config file with default values + config = &repo_model.IssuesConfig{ + EnableTimetracker: true, + AllowOnlyContributorsToTrackTime: true, + EnableDependencies: true, + } + } else { + config = unit.IssuesConfig() + } + + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeIssues, + Config: config, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) + } else if !newHasIssues { + if !unit_model.TypeExternalTracker.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) + } + if !unit_model.TypeIssues.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) + } + } + } + + currHasWiki := repo.UnitEnabled(ctx, unit_model.TypeWiki) + newHasWiki := currHasWiki + if opts.HasWiki != nil { + newHasWiki = *opts.HasWiki + } + if currHasWiki || newHasWiki { + wikiPermissions := repo.MustGetUnit(ctx, unit_model.TypeWiki).DefaultPermissions + if opts.GloballyEditableWiki != nil { + if *opts.GloballyEditableWiki { + wikiPermissions = repo_model.UnitAccessModeWrite + } else { + wikiPermissions = repo_model.UnitAccessModeRead + } + } + + if newHasWiki && opts.ExternalWiki != nil && !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + // Check that values are valid + if !validation.IsValidExternalURL(opts.ExternalWiki.ExternalWikiURL) { + err := fmt.Errorf("External wiki URL not valid") + ctx.Error(http.StatusUnprocessableEntity, "", "Invalid external wiki URL") + return err + } + + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeExternalWiki, + Config: &repo_model.ExternalWikiConfig{ + ExternalWikiURL: opts.ExternalWiki.ExternalWikiURL, + }, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } else if newHasWiki && opts.ExternalWiki == nil && !unit_model.TypeWiki.UnitGlobalDisabled() { + config := &repo_model.UnitConfig{} + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeWiki, + Config: config, + DefaultPermissions: wikiPermissions, + }) + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) + } else if !newHasWiki { + if !unit_model.TypeExternalWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) + } + if !unit_model.TypeWiki.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) + } + } else if *opts.GloballyEditableWiki { + config := &repo_model.UnitConfig{} + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeWiki, + Config: config, + DefaultPermissions: wikiPermissions, + }) + } + } + + currHasPullRequests := repo.UnitEnabled(ctx, unit_model.TypePullRequests) + newHasPullRequests := currHasPullRequests + if opts.HasPullRequests != nil { + newHasPullRequests = *opts.HasPullRequests + } + if currHasPullRequests || newHasPullRequests { + if newHasPullRequests && !unit_model.TypePullRequests.UnitGlobalDisabled() { + // We do allow setting individual PR settings through the API, so + // we get the config settings and then set them + // if those settings were provided in the opts. + unit, err := repo.GetUnit(ctx, unit_model.TypePullRequests) + var config *repo_model.PullRequestsConfig + if err != nil { + // Unit type doesn't exist so we make a new config file with default values + config = &repo_model.PullRequestsConfig{ + IgnoreWhitespaceConflicts: false, + AllowMerge: true, + AllowRebase: true, + AllowRebaseMerge: true, + AllowSquash: true, + AllowFastForwardOnly: true, + AllowManualMerge: true, + AutodetectManualMerge: false, + AllowRebaseUpdate: true, + DefaultDeleteBranchAfterMerge: false, + DefaultMergeStyle: repo_model.MergeStyleMerge, + DefaultAllowMaintainerEdit: false, + } + } else { + config = unit.PullRequestsConfig() + } + + if opts.IgnoreWhitespaceConflicts != nil { + config.IgnoreWhitespaceConflicts = *opts.IgnoreWhitespaceConflicts + } + if opts.AllowMerge != nil { + config.AllowMerge = *opts.AllowMerge + } + if opts.AllowRebase != nil { + config.AllowRebase = *opts.AllowRebase + } + if opts.AllowRebaseMerge != nil { + config.AllowRebaseMerge = *opts.AllowRebaseMerge + } + if opts.AllowSquash != nil { + config.AllowSquash = *opts.AllowSquash + } + if opts.AllowFastForwardOnly != nil { + config.AllowFastForwardOnly = *opts.AllowFastForwardOnly + } + if opts.AllowManualMerge != nil { + config.AllowManualMerge = *opts.AllowManualMerge + } + if opts.AutodetectManualMerge != nil { + config.AutodetectManualMerge = *opts.AutodetectManualMerge + } + if opts.AllowRebaseUpdate != nil { + config.AllowRebaseUpdate = *opts.AllowRebaseUpdate + } + if opts.DefaultDeleteBranchAfterMerge != nil { + config.DefaultDeleteBranchAfterMerge = *opts.DefaultDeleteBranchAfterMerge + } + if opts.DefaultMergeStyle != nil { + config.DefaultMergeStyle = repo_model.MergeStyle(*opts.DefaultMergeStyle) + } + if opts.DefaultAllowMaintainerEdit != nil { + config.DefaultAllowMaintainerEdit = *opts.DefaultAllowMaintainerEdit + } + + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypePullRequests, + Config: config, + }) + } else if !newHasPullRequests && !unit_model.TypePullRequests.UnitGlobalDisabled() { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePullRequests) + } + } + + if opts.HasProjects != nil && !unit_model.TypeProjects.UnitGlobalDisabled() { + if *opts.HasProjects { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeProjects, + }) + } else { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeProjects) + } + } + + if opts.HasReleases != nil && !unit_model.TypeReleases.UnitGlobalDisabled() { + if *opts.HasReleases { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeReleases, + }) + } else { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases) + } + } + + if opts.HasPackages != nil && !unit_model.TypePackages.UnitGlobalDisabled() { + if *opts.HasPackages { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypePackages, + }) + } else { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypePackages) + } + } + + if opts.HasActions != nil && !unit_model.TypeActions.UnitGlobalDisabled() { + if *opts.HasActions { + units = append(units, repo_model.RepoUnit{ + RepoID: repo.ID, + Type: unit_model.TypeActions, + }) + } else { + deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeActions) + } + } + + if len(units)+len(deleteUnitTypes) > 0 { + if err := repo_service.UpdateRepositoryUnits(ctx, repo, units, deleteUnitTypes); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateRepositoryUnits", err) + return err + } + } + + log.Trace("Repository advanced settings updated: %s/%s", owner.Name, repo.Name) + return nil +} + +// updateRepoArchivedState updates repo's archive state +func updateRepoArchivedState(ctx *context.APIContext, opts api.EditRepoOption) error { + repo := ctx.Repo.Repository + // archive / un-archive + if opts.Archived != nil { + if repo.IsMirror { + err := fmt.Errorf("repo is a mirror, cannot archive/un-archive") + ctx.Error(http.StatusUnprocessableEntity, err.Error(), err) + return err + } + if *opts.Archived { + if err := repo_model.SetArchiveRepoState(ctx, repo, *opts.Archived); err != nil { + log.Error("Tried to archive a repo: %s", err) + ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err) + return err + } + if err := actions_model.CleanRepoScheduleTasks(ctx, repo, true); err != nil { + log.Error("CleanRepoScheduleTasks for archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + log.Trace("Repository was archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + } else { + if err := repo_model.SetArchiveRepoState(ctx, repo, *opts.Archived); err != nil { + log.Error("Tried to un-archive a repo: %s", err) + ctx.Error(http.StatusInternalServerError, "ArchiveRepoState", err) + return err + } + if ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeActions) { + if err := actions_service.DetectAndHandleSchedules(ctx, repo); err != nil { + log.Error("DetectAndHandleSchedules for un-archived repo %s/%s: %v", ctx.Repo.Owner.Name, repo.Name, err) + } + } + log.Trace("Repository was un-archived: %s/%s", ctx.Repo.Owner.Name, repo.Name) + } + } + return nil +} + +// updateMirror updates a repo's mirror Interval and EnablePrune +func updateMirror(ctx *context.APIContext, opts api.EditRepoOption) error { + repo := ctx.Repo.Repository + + // Skip this update if the repo is not a mirror, do not return error. + // Because reporting errors only makes the logic more complex&fragile, it doesn't really help end users. + if !repo.IsMirror { + return nil + } + + // get the mirror from the repo + mirror, err := repo_model.GetMirrorByRepoID(ctx, repo.ID) + if err != nil { + log.Error("Failed to get mirror: %s", err) + ctx.Error(http.StatusInternalServerError, "MirrorInterval", err) + return err + } + + // update MirrorInterval + if opts.MirrorInterval != nil { + // MirrorInterval should be a duration + interval, err := time.ParseDuration(*opts.MirrorInterval) + if err != nil { + log.Error("Wrong format for MirrorInternal Sent: %s", err) + ctx.Error(http.StatusUnprocessableEntity, "MirrorInterval", err) + return err + } + + // Ensure the provided duration is not too short + if interval != 0 && interval < setting.Mirror.MinInterval { + err := fmt.Errorf("invalid mirror interval: %s is below minimum interval: %s", interval, setting.Mirror.MinInterval) + ctx.Error(http.StatusUnprocessableEntity, "MirrorInterval", err) + return err + } + + mirror.Interval = interval + mirror.Repo = repo + mirror.ScheduleNextUpdate() + log.Trace("Repository %s Mirror[%d] Set Interval: %s NextUpdateUnix: %s", repo.FullName(), mirror.ID, interval, mirror.NextUpdateUnix) + } + + // update EnablePrune + if opts.EnablePrune != nil { + mirror.EnablePrune = *opts.EnablePrune + log.Trace("Repository %s Mirror[%d] Set EnablePrune: %t", repo.FullName(), mirror.ID, mirror.EnablePrune) + } + + // finally update the mirror in the DB + if err := repo_model.UpdateMirror(ctx, mirror); err != nil { + log.Error("Failed to Set Mirror Interval: %s", err) + ctx.Error(http.StatusUnprocessableEntity, "MirrorInterval", err) + return err + } + + return nil +} + +// Delete one repository +func Delete(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo} repository repoDelete + // --- + // summary: Delete a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to delete + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + owner := ctx.Repo.Owner + repo := ctx.Repo.Repository + + canDelete, err := repo_module.CanUserDelete(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CanUserDelete", err) + return + } else if !canDelete { + ctx.Error(http.StatusForbidden, "", "Given user is not owner of organization.") + return + } + + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + } + + if err := repo_service.DeleteRepository(ctx, ctx.Doer, repo, true); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteRepository", err) + return + } + + log.Trace("Repository deleted: %s/%s", owner.Name, repo.Name) + ctx.Status(http.StatusNoContent) +} + +// GetIssueTemplates returns the issue templates for a repository +func GetIssueTemplates(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issue_templates repository repoGetIssueTemplates + // --- + // summary: Get available issue templates for a repository + // 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 + // responses: + // "200": + // "$ref": "#/responses/IssueTemplates" + // "404": + // "$ref": "#/responses/notFound" + ret, _ := issue.GetTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.JSON(http.StatusOK, ret) +} + +// GetIssueConfig returns the issue config for a repo +func GetIssueConfig(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issue_config repository repoGetIssueConfig + // --- + // summary: Returns the issue config for a repo + // 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 + // responses: + // "200": + // "$ref": "#/responses/RepoIssueConfig" + // "404": + // "$ref": "#/responses/notFound" + issueConfig, _ := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + ctx.JSON(http.StatusOK, issueConfig) +} + +// ValidateIssueConfig returns validation errors for the issue config +func ValidateIssueConfig(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issue_config/validate repository repoValidateIssueConfig + // --- + // summary: Returns the validation information for a issue config + // 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 + // responses: + // "200": + // "$ref": "#/responses/RepoIssueConfigValidation" + // "404": + // "$ref": "#/responses/notFound" + _, err := issue.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo) + + if err == nil { + ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: true, Message: ""}) + } else { + ctx.JSON(http.StatusOK, api.IssueConfigValidation{Valid: false, Message: err.Error()}) + } +} + +func ListRepoActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/activities/feeds repository repoListActivityFeeds + // --- + // summary: List a repository's activity feeds + // 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: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - 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/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedRepo: ctx.Repo.Repository, + OnlyPerformedByActor: true, + Actor: ctx.Doer, + IncludePrivate: true, + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} diff --git a/routers/api/v1/repo/repo_test.go b/routers/api/v1/repo/repo_test.go new file mode 100644 index 0000000..8d6ca9e --- /dev/null +++ b/routers/api/v1/repo/repo_test.go @@ -0,0 +1,86 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "testing" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/contexttest" + + "github.com/stretchr/testify/assert" +) + +func TestRepoEdit(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) + ctx.Repo.Owner = ctx.Doer + description := "new description" + website := "http://wwww.newwebsite.com" + private := true + hasIssues := false + hasWiki := false + defaultBranch := "master" + hasPullRequests := true + ignoreWhitespaceConflicts := true + allowMerge := false + allowRebase := false + allowRebaseMerge := false + allowSquashMerge := false + allowFastForwardOnlyMerge := false + archived := true + opts := api.EditRepoOption{ + Name: &ctx.Repo.Repository.Name, + Description: &description, + Website: &website, + Private: &private, + HasIssues: &hasIssues, + HasWiki: &hasWiki, + DefaultBranch: &defaultBranch, + HasPullRequests: &hasPullRequests, + IgnoreWhitespaceConflicts: &ignoreWhitespaceConflicts, + AllowMerge: &allowMerge, + AllowRebase: &allowRebase, + AllowRebaseMerge: &allowRebaseMerge, + AllowSquash: &allowSquashMerge, + AllowFastForwardOnly: &allowFastForwardOnlyMerge, + Archived: &archived, + } + + web.SetForm(ctx, &opts) + Edit(ctx) + + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + ID: 1, + }, unittest.Cond("name = ? AND is_archived = 1", *opts.Name)) +} + +func TestRepoEditNameChange(t *testing.T) { + unittest.PrepareTestEnv(t) + + ctx, _ := contexttest.MockAPIContext(t, "user2/repo1") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadUser(t, ctx, 2) + ctx.Repo.Owner = ctx.Doer + name := "newname" + opts := api.EditRepoOption{ + Name: &name, + } + + web.SetForm(ctx, &opts) + Edit(ctx) + assert.EqualValues(t, http.StatusOK, ctx.Resp.Status()) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ + ID: 1, + }, unittest.Cond("name = ?", opts.Name)) +} diff --git a/routers/api/v1/repo/star.go b/routers/api/v1/repo/star.go new file mode 100644 index 0000000..99676de --- /dev/null +++ b/routers/api/v1/repo/star.go @@ -0,0 +1,60 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListStargazers list a repository's stargazers +func ListStargazers(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/stargazers repository repoListStargazers + // --- + // summary: List a repo's stargazers + // 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: 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + stargazers, err := repo_model.GetStargazers(ctx, ctx.Repo.Repository, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetStargazers", err) + return + } + users := make([]*api.User, len(stargazers)) + for i, stargazer := range stargazers { + users[i] = convert.ToUser(ctx, stargazer, ctx.Doer) + } + + ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumStars)) + ctx.JSON(http.StatusOK, users) +} diff --git a/routers/api/v1/repo/status.go b/routers/api/v1/repo/status.go new file mode 100644 index 0000000..9e36ea0 --- /dev/null +++ b/routers/api/v1/repo/status.go @@ -0,0 +1,282 @@ +// Copyright 2017 Gitea. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + git_model "code.gitea.io/gitea/models/git" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus" +) + +// NewCommitStatus creates a new CommitStatus +func NewCommitStatus(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/statuses/{sha} repository repoCreateStatus + // --- + // summary: Create a commit status + // 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: sha + // in: path + // description: sha of the commit + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateStatusOption" + // responses: + // "201": + // "$ref": "#/responses/CommitStatus" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.CreateStatusOption) + sha := ctx.Params("sha") + if len(sha) == 0 { + ctx.Error(http.StatusBadRequest, "sha not given", nil) + return + } + status := &git_model.CommitStatus{ + State: form.State, + TargetURL: form.TargetURL, + Description: form.Description, + Context: form.Context, + } + if err := commitstatus_service.CreateCommitStatus(ctx, ctx.Repo.Repository, ctx.Doer, sha, status); err != nil { + ctx.Error(http.StatusInternalServerError, "CreateCommitStatus", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToCommitStatus(ctx, status)) +} + +// GetCommitStatuses returns all statuses for any given commit hash +func GetCommitStatuses(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/statuses/{sha} repository repoListStatuses + // --- + // summary: Get a commit's statuses + // 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: sha + // in: path + // description: sha of the commit + // type: string + // required: true + // - name: sort + // in: query + // description: type of sort + // type: string + // enum: [oldest, recentupdate, leastupdate, leastindex, highestindex] + // required: false + // - name: state + // in: query + // description: type of state + // type: string + // enum: [pending, success, error, failure, warning] + // 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/CommitStatusList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + getCommitStatuses(ctx, ctx.Params("sha")) +} + +// GetCommitStatusesByRef returns all statuses for any given commit ref +func GetCommitStatusesByRef(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/commits/{ref}/statuses repository repoListStatusesByRef + // --- + // summary: Get a commit's statuses, by branch/tag/commit reference + // 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: ref + // in: path + // description: name of branch/tag/commit + // type: string + // required: true + // - name: sort + // in: query + // description: type of sort + // type: string + // enum: [oldest, recentupdate, leastupdate, leastindex, highestindex] + // required: false + // - name: state + // in: query + // description: type of state + // type: string + // enum: [pending, success, error, failure, warning] + // 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/CommitStatusList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + filter := utils.ResolveRefOrSha(ctx, ctx.Params("ref")) + if ctx.Written() { + return + } + + getCommitStatuses(ctx, filter) // By default filter is maybe the raw SHA +} + +func getCommitStatuses(ctx *context.APIContext, sha string) { + if len(sha) == 0 { + ctx.Error(http.StatusBadRequest, "ref/sha not given", nil) + return + } + sha = utils.MustConvertToSHA1(ctx.Base, ctx.Repo, sha) + repo := ctx.Repo.Repository + + listOptions := utils.GetListOptions(ctx) + + statuses, maxResults, err := db.FindAndCount[git_model.CommitStatus](ctx, &git_model.CommitStatusOptions{ + ListOptions: listOptions, + RepoID: repo.ID, + SHA: sha, + SortType: ctx.FormTrim("sort"), + State: ctx.FormTrim("state"), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommitStatuses", fmt.Errorf("GetCommitStatuses[%s, %s, %d]: %w", repo.FullName(), sha, ctx.FormInt("page"), err)) + return + } + + apiStatuses := make([]*api.CommitStatus, 0, len(statuses)) + for _, status := range statuses { + apiStatuses = append(apiStatuses, convert.ToCommitStatus(ctx, status)) + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + + ctx.JSON(http.StatusOK, apiStatuses) +} + +// GetCombinedCommitStatusByRef returns the combined status for any given commit hash +func GetCombinedCommitStatusByRef(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/commits/{ref}/status repository repoGetCombinedStatusByRef + // --- + // summary: Get a commit's combined status, by branch/tag/commit reference + // 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: ref + // in: path + // description: name of branch/tag/commit + // type: string + // required: true + // - 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/CombinedStatus" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + sha := utils.ResolveRefOrSha(ctx, ctx.Params("ref")) + if ctx.Written() { + return + } + + repo := ctx.Repo.Repository + + statuses, count, err := git_model.GetLatestCommitStatus(ctx, repo.ID, sha, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetLatestCommitStatus", fmt.Errorf("GetLatestCommitStatus[%s, %s]: %w", repo.FullName(), sha, err)) + return + } + + 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/subscriber.go b/routers/api/v1/repo/subscriber.go new file mode 100644 index 0000000..8584182 --- /dev/null +++ b/routers/api/v1/repo/subscriber.go @@ -0,0 +1,60 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListSubscribers list a repo's subscribers (i.e. watchers) +func ListSubscribers(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/subscribers repository repoListSubscribers + // --- + // summary: List a repo's watchers + // 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: 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + subscribers, err := repo_model.GetRepoWatchers(ctx, ctx.Repo.Repository.ID, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRepoWatchers", err) + return + } + users := make([]*api.User, len(subscribers)) + for i, subscriber := range subscribers { + users[i] = convert.ToUser(ctx, subscriber, ctx.Doer) + } + + ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumWatches)) + ctx.JSON(http.StatusOK, users) +} diff --git a/routers/api/v1/repo/tag.go b/routers/api/v1/repo/tag.go new file mode 100644 index 0000000..7dbdd1f --- /dev/null +++ b/routers/api/v1/repo/tag.go @@ -0,0 +1,668 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + git_model "code.gitea.io/gitea/models/git" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + releaseservice "code.gitea.io/gitea/services/release" +) + +// ListTags list all the tags of a repository +func ListTags(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tags repository repoListTags + // --- + // summary: List a repository's tags + // 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: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results, default maximum page size is 50 + // type: integer + // responses: + // "200": + // "$ref": "#/responses/TagList" + // "404": + // "$ref": "#/responses/notFound" + + listOpts := utils.GetListOptions(ctx) + + tags, total, err := ctx.Repo.GitRepo.GetTagInfos(listOpts.Page, listOpts.PageSize) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTags", err) + return + } + + apiTags := make([]*api.Tag, len(tags)) + for i := range tags { + tags[i].ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tags[i].Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err) + return + } + + apiTags[i] = convert.ToTag(ctx.Repo.Repository, tags[i]) + } + + ctx.SetTotalCountHeader(int64(total)) + ctx.JSON(http.StatusOK, &apiTags) +} + +// GetAnnotatedTag get the tag of a repository. +func GetAnnotatedTag(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/tags/{sha} repository GetAnnotatedTag + // --- + // summary: Gets the tag object of an annotated tag (not lightweight tags) + // 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: sha + // in: path + // description: sha of the tag. The Git tags API only supports annotated tag objects, not lightweight tags. + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/AnnotatedTag" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + sha := ctx.Params("sha") + if len(sha) == 0 { + ctx.Error(http.StatusBadRequest, "", "SHA not provided") + return + } + + if tag, err := ctx.Repo.GitRepo.GetAnnotatedTag(sha); err != nil { + ctx.Error(http.StatusBadRequest, "GetAnnotatedTag", err) + } else { + commit, err := tag.Commit(ctx.Repo.GitRepo) + if err != nil { + ctx.Error(http.StatusBadRequest, "GetAnnotatedTag", err) + } + + tag.ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tag.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToAnnotatedTag(ctx, ctx.Repo.Repository, tag, commit)) + } +} + +// GetTag get the tag of a repository +func GetTag(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tags/{tag} repository repoGetTag + // --- + // summary: Get the tag of a repository by tag name + // 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: tag + // in: path + // description: name of tag + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Tag" + // "404": + // "$ref": "#/responses/notFound" + tagName := ctx.Params("*") + + tag, err := ctx.Repo.GitRepo.GetTag(tagName) + if err != nil { + ctx.NotFound(tagName) + return + } + + tag.ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tag.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToTag(ctx.Repo.Repository, tag)) +} + +// CreateTag create a new git tag in a repository +func CreateTag(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/tags repository repoCreateTag + // --- + // summary: Create a new git tag in a repository + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateTagOption" + // responses: + // "201": + // "$ref": "#/responses/Tag" + // "404": + // "$ref": "#/responses/notFound" + // "405": + // "$ref": "#/responses/empty" + // "409": + // "$ref": "#/responses/conflict" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + form := web.GetForm(ctx).(*api.CreateTagOption) + + // If target is not provided use default branch + if len(form.Target) == 0 { + form.Target = ctx.Repo.Repository.DefaultBranch + } + + commit, err := ctx.Repo.GitRepo.GetCommit(form.Target) + if err != nil { + ctx.Error(http.StatusNotFound, "target not found", fmt.Errorf("target not found: %w", err)) + return + } + + if err := releaseservice.CreateNewTag(ctx, ctx.Doer, ctx.Repo.Repository, commit.ID.String(), form.TagName, form.Message); err != nil { + if models.IsErrTagAlreadyExists(err) { + ctx.Error(http.StatusConflict, "tag exist", err) + return + } + if models.IsErrProtectedTagName(err) { + ctx.Error(http.StatusUnprocessableEntity, "CreateNewTag", "user not allowed to create protected tag") + return + } + + ctx.InternalServerError(err) + return + } + + tag, err := ctx.Repo.GitRepo.GetTag(form.TagName) + if err != nil { + ctx.InternalServerError(err) + return + } + + tag.ArchiveDownloadCount, err = repo_model.GetArchiveDownloadCountForTagName(ctx, ctx.Repo.Repository.ID, tag.Name) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetTagArchiveDownloadCountForName", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToTag(ctx.Repo.Repository, tag)) +} + +// DeleteTag delete a specific tag of in a repository by name +func DeleteTag(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/tags/{tag} repository repoDeleteTag + // --- + // summary: Delete a repository's tag by name + // 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: tag + // in: path + // description: name of tag to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "405": + // "$ref": "#/responses/empty" + // "409": + // "$ref": "#/responses/conflict" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + tagName := ctx.Params("*") + + tag, err := repo_model.GetRelease(ctx, ctx.Repo.Repository.ID, tagName) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.NotFound() + return + } + ctx.Error(http.StatusInternalServerError, "GetRelease", err) + return + } + + if !tag.IsTag { + ctx.Error(http.StatusConflict, "IsTag", errors.New("a tag attached to a release cannot be deleted directly")) + return + } + + if err = releaseservice.DeleteReleaseByID(ctx, ctx.Repo.Repository, tag, ctx.Doer, true); err != nil { + if models.IsErrProtectedTagName(err) { + ctx.Error(http.StatusUnprocessableEntity, "delTag", "user not allowed to delete protected tag") + return + } + ctx.Error(http.StatusInternalServerError, "DeleteReleaseByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// ListTagProtection lists tag protections for a repo +func ListTagProtection(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tag_protections repository repoListTagProtection + // --- + // summary: List tag protections for a repository + // 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 + // responses: + // "200": + // "$ref": "#/responses/TagProtectionList" + + repo := ctx.Repo.Repository + pts, err := git_model.GetProtectedTags(ctx, repo.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTags", err) + return + } + apiPts := make([]*api.TagProtection, len(pts)) + for i := range pts { + apiPts[i] = convert.ToTagProtection(ctx, pts[i], repo) + } + + ctx.JSON(http.StatusOK, apiPts) +} + +// GetTagProtection gets a tag protection +func GetTagProtection(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/tag_protections/{id} repository repoGetTagProtection + // --- + // summary: Get a specific tag protection for the repository + // 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: id + // in: path + // description: id of the tag protect to get + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/TagProtection" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || repo.ID != pt.RepoID { + ctx.NotFound() + return + } + + ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo)) +} + +// CreateTagProtection creates a tag protection for a repo +func CreateTagProtection(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/tag_protections repository repoCreateTagProtection + // --- + // summary: Create a tag protections for a repository + // consumes: + // - application/json + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateTagProtectionOption" + // responses: + // "201": + // "$ref": "#/responses/TagProtection" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateTagProtectionOption) + repo := ctx.Repo.Repository + + namePattern := strings.TrimSpace(form.NamePattern) + if namePattern == "" { + ctx.Error(http.StatusBadRequest, "name_pattern are empty", "name_pattern are empty") + return + } + + if len(form.WhitelistUsernames) == 0 && len(form.WhitelistTeams) == 0 { + ctx.Error(http.StatusBadRequest, "both whitelist_usernames and whitelist_teams are empty", "both whitelist_usernames and whitelist_teams are empty") + return + } + + pt, err := git_model.GetProtectedTagByNamePattern(ctx, repo.ID, namePattern) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectTagOfRepo", err) + return + } else if pt != nil { + ctx.Error(http.StatusForbidden, "Create tag protection", "Tag protection already exist") + return + } + + var whitelistUsers, whitelistTeams []int64 + whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + + if repo.Owner.IsOrganization() { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } + + protectTag := &git_model.ProtectedTag{ + RepoID: repo.ID, + NamePattern: strings.TrimSpace(namePattern), + AllowlistUserIDs: whitelistUsers, + AllowlistTeamIDs: whitelistTeams, + } + if err := git_model.InsertProtectedTag(ctx, protectTag); err != nil { + ctx.Error(http.StatusInternalServerError, "InsertProtectedTag", err) + return + } + + pt, err = git_model.GetProtectedTagByID(ctx, protectTag.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.Error(http.StatusInternalServerError, "New tag protection not found", err) + return + } + + ctx.JSON(http.StatusCreated, convert.ToTagProtection(ctx, pt, repo)) +} + +// EditTagProtection edits a tag protection for a repo +func EditTagProtection(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/tag_protections/{id} repository repoEditTagProtection + // --- + // summary: Edit a tag protections for a repository. Only fields that are set will be changed + // consumes: + // - application/json + // 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: id + // in: path + // description: id of protected tag + // type: integer + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditTagProtectionOption" + // responses: + // "200": + // "$ref": "#/responses/TagProtection" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + // "423": + // "$ref": "#/responses/repoArchivedError" + + repo := ctx.Repo.Repository + form := web.GetForm(ctx).(*api.EditTagProtectionOption) + + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.NotFound() + return + } + + if form.NamePattern != nil { + pt.NamePattern = *form.NamePattern + } + + var whitelistUsers, whitelistTeams []int64 + if form.WhitelistTeams != nil { + if repo.Owner.IsOrganization() { + whitelistTeams, err = organization.GetTeamIDsByNames(ctx, repo.OwnerID, form.WhitelistTeams, false) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "Team does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetTeamIDsByNames", err) + return + } + } + pt.AllowlistTeamIDs = whitelistTeams + } + + if form.WhitelistUsernames != nil { + whitelistUsers, err = user_model.GetUserIDsByNames(ctx, form.WhitelistUsernames, false) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "User does not exist", err) + return + } + ctx.Error(http.StatusInternalServerError, "GetUserIDsByNames", err) + return + } + pt.AllowlistUserIDs = whitelistUsers + } + + err = git_model.UpdateProtectedTag(ctx, pt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateProtectedTag", err) + return + } + + pt, err = git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.Error(http.StatusInternalServerError, "New tag protection not found", "New tag protection not found") + return + } + + ctx.JSON(http.StatusOK, convert.ToTagProtection(ctx, pt, repo)) +} + +// DeleteTagProtection +func DeleteTagProtection(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/tag_protections/{id} repository repoDeleteTagProtection + // --- + // summary: Delete a specific tag protection for the repository + // 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: id + // in: path + // description: id of protected tag + // type: integer + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + repo := ctx.Repo.Repository + id := ctx.ParamsInt64(":id") + pt, err := git_model.GetProtectedTagByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetProtectedTagByID", err) + return + } + + if pt == nil || pt.RepoID != repo.ID { + ctx.NotFound() + return + } + + err = git_model.DeleteProtectedTag(ctx, pt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteProtectedTag", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/teams.go b/routers/api/v1/repo/teams.go new file mode 100644 index 0000000..0ecf3a3 --- /dev/null +++ b/routers/api/v1/repo/teams.go @@ -0,0 +1,235 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + org_service "code.gitea.io/gitea/services/org" + repo_service "code.gitea.io/gitea/services/repository" +) + +// ListTeams list a repository's teams +func ListTeams(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/teams repository repoListTeams + // --- + // summary: List a repository's teams + // 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 + // responses: + // "200": + // "$ref": "#/responses/TeamList" + // "404": + // "$ref": "#/responses/notFound" + + if !ctx.Repo.Owner.IsOrganization() { + ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization") + return + } + + teams, err := organization.GetRepoTeams(ctx, ctx.Repo.Repository) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiTeams, err := convert.ToTeams(ctx, teams, false) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, apiTeams) +} + +// IsTeam check if a team is assigned to a repository +func IsTeam(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/teams/{team} repository repoCheckTeam + // --- + // summary: Check if a team is assigned to a repository + // 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: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Team" + // "404": + // "$ref": "#/responses/notFound" + // "405": + // "$ref": "#/responses/error" + + if !ctx.Repo.Owner.IsOrganization() { + ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization") + return + } + + team := getTeamByParam(ctx) + if team == nil { + return + } + + if repo_service.HasRepository(ctx, team, ctx.Repo.Repository.ID) { + apiTeam, err := convert.ToTeam(ctx, team) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusOK, apiTeam) + return + } + + ctx.NotFound() +} + +// AddTeam add a team to a repository +func AddTeam(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/teams/{team} repository repoAddTeam + // --- + // summary: Add a team to a repository + // 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: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "405": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + changeRepoTeam(ctx, true) +} + +// DeleteTeam delete a team from a repository +func DeleteTeam(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/teams/{team} repository repoDeleteTeam + // --- + // summary: Delete a team from a repository + // 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: team + // in: path + // description: team name + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "422": + // "$ref": "#/responses/validationError" + // "405": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + changeRepoTeam(ctx, false) +} + +func changeRepoTeam(ctx *context.APIContext, add bool) { + if !ctx.Repo.Owner.IsOrganization() { + ctx.Error(http.StatusMethodNotAllowed, "noOrg", "repo is not owned by an organization") + } + if !ctx.Repo.Owner.RepoAdminChangeTeamAccess && !ctx.Repo.IsOwner() { + ctx.Error(http.StatusForbidden, "noAdmin", "user is nor repo admin nor owner") + return + } + + team := getTeamByParam(ctx) + if team == nil { + return + } + + repoHasTeam := repo_service.HasRepository(ctx, team, ctx.Repo.Repository.ID) + var err error + if add { + if repoHasTeam { + ctx.Error(http.StatusUnprocessableEntity, "alreadyAdded", fmt.Errorf("team '%s' is already added to repo", team.Name)) + return + } + err = org_service.TeamAddRepository(ctx, team, ctx.Repo.Repository) + } else { + if !repoHasTeam { + ctx.Error(http.StatusUnprocessableEntity, "notAdded", fmt.Errorf("team '%s' was not added to repo", team.Name)) + return + } + err = repo_service.RemoveRepositoryFromTeam(ctx, team, ctx.Repo.Repository.ID) + } + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func getTeamByParam(ctx *context.APIContext) *organization.Team { + team, err := organization.GetTeam(ctx, ctx.Repo.Owner.ID, ctx.Params(":team")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusNotFound, "TeamNotExit", err) + return nil + } + ctx.InternalServerError(err) + return nil + } + return team +} diff --git a/routers/api/v1/repo/topic.go b/routers/api/v1/repo/topic.go new file mode 100644 index 0000000..1d8e675 --- /dev/null +++ b/routers/api/v1/repo/topic.go @@ -0,0 +1,305 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListTopics returns list of current topics for repo +func ListTopics(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/topics repository repoListTopics + // --- + // summary: Get list of topics that a repository has + // 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: 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/TopicNames" + // "404": + // "$ref": "#/responses/notFound" + + opts := &repo_model.FindTopicOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + } + + topics, total, err := repo_model.FindTopics(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + topicNames := make([]string, len(topics)) + for i, topic := range topics { + topicNames[i] = topic.Name + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, map[string]any{ + "topics": topicNames, + }) +} + +// UpdateTopics updates repo with a new set of topics +func UpdateTopics(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/topics repository repoUpdateTopics + // --- + // summary: Replace list of topics for a repository + // 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: body + // in: body + // schema: + // "$ref": "#/definitions/RepoTopicOptions" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/invalidTopicsError" + + form := web.GetForm(ctx).(*api.RepoTopicOptions) + topicNames := form.Topics + validTopics, invalidTopics := repo_model.SanitizeAndValidateTopics(topicNames) + + if len(validTopics) > 25 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": nil, + "message": "Exceeding maximum number of topics per repo", + }) + return + } + + if len(invalidTopics) > 0 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": invalidTopics, + "message": "Topic names are invalid", + }) + return + } + + err := repo_model.SaveTopics(ctx, ctx.Repo.Repository.ID, validTopics...) + if err != nil { + log.Error("SaveTopics failed: %v", err) + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// AddTopic adds a topic name to a repo +func AddTopic(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/topics/{topic} repository repoAddTopic + // --- + // summary: Add a topic to a repository + // 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: topic + // in: path + // description: name of the topic to add + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/invalidTopicsError" + + topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic"))) + + if !repo_model.ValidateTopic(topicName) { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": topicName, + "message": "Topic name is invalid", + }) + return + } + + // Prevent adding more topics than allowed to repo + count, err := repo_model.CountTopics(ctx, &repo_model.FindTopicOptions{ + RepoID: ctx.Repo.Repository.ID, + }) + if err != nil { + log.Error("CountTopics failed: %v", err) + ctx.InternalServerError(err) + return + } + if count >= 25 { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "message": "Exceeding maximum allowed topics per repo.", + }) + return + } + + _, err = repo_model.AddTopic(ctx, ctx.Repo.Repository.ID, topicName) + if err != nil { + log.Error("AddTopic failed: %v", err) + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteTopic removes topic name from repo +func DeleteTopic(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/topics/{topic} repository repoDeleteTopic + // --- + // summary: Delete a topic from a repository + // 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: topic + // in: path + // description: name of the topic to delete + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/invalidTopicsError" + + topicName := strings.TrimSpace(strings.ToLower(ctx.Params(":topic"))) + + if !repo_model.ValidateTopic(topicName) { + ctx.JSON(http.StatusUnprocessableEntity, map[string]any{ + "invalidTopics": topicName, + "message": "Topic name is invalid", + }) + return + } + + topic, err := repo_model.DeleteTopic(ctx, ctx.Repo.Repository.ID, topicName) + if err != nil { + log.Error("DeleteTopic failed: %v", err) + ctx.InternalServerError(err) + return + } + + if topic == nil { + ctx.NotFound() + return + } + + ctx.Status(http.StatusNoContent) +} + +// TopicSearch search for creating topic +func TopicSearch(ctx *context.APIContext) { + // swagger:operation GET /topics/search repository topicSearch + // --- + // summary: search topics via keyword + // produces: + // - application/json + // parameters: + // - name: q + // in: query + // description: keywords to search + // required: true + // type: string + // - 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/TopicListResponse" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + opts := &repo_model.FindTopicOptions{ + Keyword: ctx.FormString("q"), + ListOptions: utils.GetListOptions(ctx), + } + + topics, total, err := repo_model.FindTopics(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + topicResponses := make([]*api.TopicResponse, len(topics)) + for i, topic := range topics { + topicResponses[i] = convert.ToTopicResponse(topic) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, map[string]any{ + "topics": topicResponses, + }) +} diff --git a/routers/api/v1/repo/transfer.go b/routers/api/v1/repo/transfer.go new file mode 100644 index 0000000..0715aed --- /dev/null +++ b/routers/api/v1/repo/transfer.go @@ -0,0 +1,254 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "fmt" + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + quota_model "code.gitea.io/gitea/models/quota" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + repo_service "code.gitea.io/gitea/services/repository" +) + +// Transfer transfers the ownership of a repository +func Transfer(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/transfer repository repoTransfer + // --- + // summary: Transfer a repo ownership + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to transfer + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to transfer + // type: string + // required: true + // - name: body + // in: body + // description: "Transfer Options" + // required: true + // schema: + // "$ref": "#/definitions/TransferRepoOption" + // responses: + // "202": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "422": + // "$ref": "#/responses/validationError" + + opts := web.GetForm(ctx).(*api.TransferRepoOption) + + newOwner, err := user_model.GetUserByName(ctx, opts.NewOwner) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusNotFound, "", "The new owner does not exist or cannot be found") + return + } + ctx.InternalServerError(err) + return + } + + if newOwner.Type == user_model.UserTypeOrganization { + if !ctx.Doer.IsAdmin && newOwner.Visibility == api.VisibleTypePrivate && !organization.OrgFromUser(newOwner).HasMemberWithUserID(ctx, ctx.Doer.ID) { + // The user shouldn't know about this organization + ctx.Error(http.StatusNotFound, "", "The new owner does not exist or cannot be found") + return + } + } + + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, newOwner.ID, newOwner.Name) { + return + } + + var teams []*organization.Team + if opts.TeamIDs != nil { + if !newOwner.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "repoTransfer", "Teams can only be added to organization-owned repositories") + return + } + + org := convert.ToOrganization(ctx, organization.OrgFromUser(newOwner)) + for _, tID := range *opts.TeamIDs { + team, err := organization.GetTeamByID(ctx, tID) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "team", fmt.Errorf("team %d not found", tID)) + return + } + + if team.OrgID != org.ID { + ctx.Error(http.StatusForbidden, "team", fmt.Errorf("team %d belongs not to org %d", tID, org.ID)) + return + } + + teams = append(teams, team) + } + } + + if ctx.Repo.GitRepo != nil { + ctx.Repo.GitRepo.Close() + ctx.Repo.GitRepo = nil + } + + oldFullname := ctx.Repo.Repository.FullName() + + if err := repo_service.StartRepositoryTransfer(ctx, ctx.Doer, newOwner, ctx.Repo.Repository, teams); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "StartRepositoryTransfer", err) + return + } + + if models.IsErrRepoTransferInProgress(err) { + ctx.Error(http.StatusConflict, "StartRepositoryTransfer", err) + return + } + + if repo_model.IsErrRepoAlreadyExist(err) { + ctx.Error(http.StatusUnprocessableEntity, "StartRepositoryTransfer", err) + return + } + + ctx.InternalServerError(err) + return + } + + if ctx.Repo.Repository.Status == repo_model.RepositoryPendingTransfer { + log.Trace("Repository transfer initiated: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) + ctx.JSON(http.StatusCreated, convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeAdmin})) + return + } + + log.Trace("Repository transferred: %s -> %s", oldFullname, ctx.Repo.Repository.FullName()) + ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx, ctx.Repo.Repository, access_model.Permission{AccessMode: perm.AccessModeAdmin})) +} + +// AcceptTransfer accept a repo transfer +func AcceptTransfer(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/transfer/accept repository acceptRepoTransfer + // --- + // summary: Accept a repo transfer + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to transfer + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to transfer + // type: string + // required: true + // responses: + // "202": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + + err := acceptOrRejectRepoTransfer(ctx, true) + if ctx.Written() { + return + } + if err != nil { + ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err) + return + } + + ctx.JSON(http.StatusAccepted, convert.ToRepo(ctx, ctx.Repo.Repository, ctx.Repo.Permission)) +} + +// RejectTransfer reject a repo transfer +func RejectTransfer(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/transfer/reject repository rejectRepoTransfer + // --- + // summary: Reject a repo transfer + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo to transfer + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to transfer + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/Repository" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + err := acceptOrRejectRepoTransfer(ctx, false) + if ctx.Written() { + return + } + if err != nil { + ctx.Error(http.StatusInternalServerError, "acceptOrRejectRepoTransfer", err) + return + } + + ctx.JSON(http.StatusOK, convert.ToRepo(ctx, ctx.Repo.Repository, ctx.Repo.Permission)) +} + +func acceptOrRejectRepoTransfer(ctx *context.APIContext, accept bool) error { + repoTransfer, err := models.GetPendingRepositoryTransfer(ctx, ctx.Repo.Repository) + if err != nil { + if models.IsErrNoPendingTransfer(err) { + ctx.NotFound() + return nil + } + return err + } + + if err := repoTransfer.LoadAttributes(ctx); err != nil { + return err + } + + if !repoTransfer.CanUserAcceptTransfer(ctx, ctx.Doer) { + ctx.Error(http.StatusForbidden, "CanUserAcceptTransfer", nil) + return fmt.Errorf("user does not have permissions to do this") + } + + if accept { + recipient := repoTransfer.Recipient + if !ctx.CheckQuota(quota_model.LimitSubjectSizeReposAll, recipient.ID, recipient.Name) { + return nil + } + + return repo_service.TransferOwnership(ctx, repoTransfer.Doer, repoTransfer.Recipient, ctx.Repo.Repository, repoTransfer.Teams) + } + + return repo_service.CancelRepositoryTransfer(ctx, ctx.Repo.Repository) +} diff --git a/routers/api/v1/repo/tree.go b/routers/api/v1/repo/tree.go new file mode 100644 index 0000000..353a996 --- /dev/null +++ b/routers/api/v1/repo/tree.go @@ -0,0 +1,70 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +// GetTree get the tree of a repository. +func GetTree(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/git/trees/{sha} repository GetTree + // --- + // summary: Gets the tree of a repository. + // 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: sha + // in: path + // description: sha of the commit + // type: string + // required: true + // - name: recursive + // in: query + // description: show all directories and files + // required: false + // type: boolean + // - name: page + // in: query + // description: page number; the 'truncated' field in the response will be true if there are still more items after this page, false if the last page + // required: false + // type: integer + // - name: per_page + // in: query + // description: number of items per page + // required: false + // type: integer + // responses: + // "200": + // "$ref": "#/responses/GitTreeResponse" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + sha := ctx.Params(":sha") + if len(sha) == 0 { + ctx.Error(http.StatusBadRequest, "", "sha not provided") + return + } + if tree, err := files_service.GetTreeBySHA(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, sha, ctx.FormInt("page"), ctx.FormInt("per_page"), ctx.FormBool("recursive")); err != nil { + ctx.Error(http.StatusBadRequest, "", err.Error()) + } else { + ctx.SetTotalCountHeader(int64(tree.TotalCount)) + ctx.JSON(http.StatusOK, tree) + } +} diff --git a/routers/api/v1/repo/wiki.go b/routers/api/v1/repo/wiki.go new file mode 100644 index 0000000..12aaa8e --- /dev/null +++ b/routers/api/v1/repo/wiki.go @@ -0,0 +1,536 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + notify_service "code.gitea.io/gitea/services/notify" + wiki_service "code.gitea.io/gitea/services/wiki" +) + +// NewWikiPage response for wiki create request +func NewWikiPage(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/wiki/new repository repoCreateWikiPage + // --- + // summary: Create a wiki page + // consumes: + // - 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: body + // in: body + // schema: + // "$ref": "#/definitions/CreateWikiPageOptions" + // responses: + // "201": + // "$ref": "#/responses/WikiPage" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateWikiPageOptions) + + if util.IsEmptyString(form.Title) { + ctx.Error(http.StatusBadRequest, "emptyTitle", nil) + return + } + + wikiName := wiki_service.UserTitleToWebPath("", form.Title) + + if len(form.Message) == 0 { + form.Message = fmt.Sprintf("Add %q", form.Title) + } + + content, err := base64.StdEncoding.DecodeString(form.ContentBase64) + if err != nil { + ctx.Error(http.StatusBadRequest, "invalid base64 encoding of content", err) + return + } + form.ContentBase64 = string(content) + + if err := wiki_service.AddWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName, form.ContentBase64, form.Message); err != nil { + if repo_model.IsErrWikiReservedName(err) { + ctx.Error(http.StatusBadRequest, "IsErrWikiReservedName", err) + } else if repo_model.IsErrWikiAlreadyExist(err) { + ctx.Error(http.StatusBadRequest, "IsErrWikiAlreadyExists", err) + } else { + ctx.Error(http.StatusInternalServerError, "AddWikiPage", err) + } + return + } + + wikiPage := getWikiPage(ctx, wikiName) + + if !ctx.Written() { + notify_service.NewWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName), form.Message) + ctx.JSON(http.StatusCreated, wikiPage) + } +} + +// EditWikiPage response for wiki modify request +func EditWikiPage(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/wiki/page/{pageName} repository repoEditWikiPage + // --- + // summary: Edit a wiki page + // consumes: + // - 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: pageName + // in: path + // description: name of the page + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateWikiPageOptions" + // responses: + // "200": + // "$ref": "#/responses/WikiPage" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "413": + // "$ref": "#/responses/quotaExceeded" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateWikiPageOptions) + + oldWikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) + newWikiName := wiki_service.UserTitleToWebPath("", form.Title) + + if len(newWikiName) == 0 { + newWikiName = oldWikiName + } + + if len(form.Message) == 0 { + form.Message = fmt.Sprintf("Update %q", newWikiName) + } + + content, err := base64.StdEncoding.DecodeString(form.ContentBase64) + if err != nil { + ctx.Error(http.StatusBadRequest, "invalid base64 encoding of content", err) + return + } + form.ContentBase64 = string(content) + + if err := wiki_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, oldWikiName, newWikiName, form.ContentBase64, form.Message); err != nil { + ctx.Error(http.StatusInternalServerError, "EditWikiPage", err) + return + } + + wikiPage := getWikiPage(ctx, newWikiName) + + if !ctx.Written() { + notify_service.EditWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(newWikiName), form.Message) + ctx.JSON(http.StatusOK, wikiPage) + } +} + +func getWikiPage(ctx *context.APIContext, wikiName wiki_service.WebPath) *api.WikiPage { + wikiRepo, commit := findWikiRepoCommit(ctx) + if wikiRepo != nil { + defer wikiRepo.Close() + } + if ctx.Written() { + return nil + } + + // lookup filename in wiki - get filecontent, real filename + content, pageFilename := wikiContentsByName(ctx, commit, wikiName, false) + if ctx.Written() { + return nil + } + + sidebarContent, _ := wikiContentsByName(ctx, commit, "_Sidebar", true) + if ctx.Written() { + return nil + } + + footerContent, _ := wikiContentsByName(ctx, commit, "_Footer", true) + if ctx.Written() { + return nil + } + + // get commit count - wiki revisions + commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.GetWikiBranchName(), pageFilename) + + // Get last change information. + lastCommit, err := wikiRepo.GetCommitByPath(pageFilename) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommitByPath", err) + return nil + } + + return &api.WikiPage{ + WikiPageMetaData: wiki_service.ToWikiPageMetaData(wikiName, lastCommit, ctx.Repo.Repository), + ContentBase64: content, + CommitCount: commitsCount, + Sidebar: sidebarContent, + Footer: footerContent, + } +} + +// DeleteWikiPage delete wiki page +func DeleteWikiPage(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/wiki/page/{pageName} repository repoDeleteWikiPage + // --- + // summary: Delete a wiki page + // 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: pageName + // in: path + // description: name of the page + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + wikiName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) + + if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, wikiName); err != nil { + if err.Error() == "file does not exist" { + ctx.NotFound(err) + return + } + ctx.Error(http.StatusInternalServerError, "DeleteWikiPage", err) + return + } + + notify_service.DeleteWikiPage(ctx, ctx.Doer, ctx.Repo.Repository, string(wikiName)) + + ctx.Status(http.StatusNoContent) +} + +// ListWikiPages get wiki pages list +func ListWikiPages(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/wiki/pages repository repoGetWikiPages + // --- + // summary: Get all wiki pages + // 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: 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/WikiPageList" + // "404": + // "$ref": "#/responses/notFound" + + wikiRepo, commit := findWikiRepoCommit(ctx) + if wikiRepo != nil { + defer wikiRepo.Close() + } + if ctx.Written() { + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + limit := ctx.FormInt("limit") + if limit <= 1 { + limit = setting.API.DefaultPagingNum + } + + skip := (page - 1) * limit + max := page * limit + + entries, err := commit.ListEntries() + if err != nil { + ctx.ServerError("ListEntries", err) + return + } + pages := make([]*api.WikiPageMetaData, 0, len(entries)) + for i, entry := range entries { + if i < skip || i >= max || !entry.IsRegular() { + continue + } + c, err := wikiRepo.GetCommitByPath(entry.Name()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetCommit", err) + return + } + wikiName, err := wiki_service.GitPathToWebPath(entry.Name()) + if err != nil { + if repo_model.IsErrWikiInvalidFileName(err) { + continue + } + ctx.Error(http.StatusInternalServerError, "WikiFilenameToName", err) + return + } + pages = append(pages, wiki_service.ToWikiPageMetaData(wikiName, c, ctx.Repo.Repository)) + } + + ctx.SetTotalCountHeader(int64(len(entries))) + ctx.JSON(http.StatusOK, pages) +} + +// GetWikiPage get single wiki page +func GetWikiPage(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/wiki/page/{pageName} repository repoGetWikiPage + // --- + // summary: Get a wiki page + // 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: pageName + // in: path + // description: name of the page + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/WikiPage" + // "404": + // "$ref": "#/responses/notFound" + + // get requested pagename + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) + + wikiPage := getWikiPage(ctx, pageName) + if !ctx.Written() { + ctx.JSON(http.StatusOK, wikiPage) + } +} + +// ListPageRevisions renders file revision list of wiki page +func ListPageRevisions(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/wiki/revisions/{pageName} repository repoGetWikiPageRevisions + // --- + // summary: Get revisions of a wiki page + // 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: pageName + // in: path + // description: name of the page + // type: string + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // responses: + // "200": + // "$ref": "#/responses/WikiCommitList" + // "404": + // "$ref": "#/responses/notFound" + + wikiRepo, commit := findWikiRepoCommit(ctx) + if wikiRepo != nil { + defer wikiRepo.Close() + } + if ctx.Written() { + return + } + + // get requested pagename + pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw(":pageName")) + if len(pageName) == 0 { + pageName = "Home" + } + + // lookup filename in wiki - get filecontent, gitTree entry , real filename + _, pageFilename := wikiContentsByName(ctx, commit, pageName, false) + if ctx.Written() { + return + } + + // get commit count - wiki revisions + commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.GetWikiBranchName(), pageFilename) + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + // get Commit Count + commitsHistory, err := wikiRepo.CommitsByFileAndRange( + git.CommitsByFileAndRangeOptions{ + Revision: ctx.Repo.Repository.GetWikiBranchName(), + File: pageFilename, + Page: page, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "CommitsByFileAndRange", err) + return + } + + ctx.SetTotalCountHeader(commitsCount) + ctx.JSON(http.StatusOK, convert.ToWikiCommitList(commitsHistory, commitsCount)) +} + +// findEntryForFile finds the tree entry for a target filepath. +func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error) { + entry, err := commit.GetTreeEntryByPath(target) + if err != nil { + return nil, err + } + if entry != nil { + return entry, nil + } + + // Then the unescaped, shortest alternative + var unescapedTarget string + if unescapedTarget, err = url.QueryUnescape(target); err != nil { + return nil, err + } + return commit.GetTreeEntryByPath(unescapedTarget) +} + +// findWikiRepoCommit opens the wiki repo and returns the latest commit, writing to context on error. +// The caller is responsible for closing the returned repo again +func findWikiRepoCommit(ctx *context.APIContext) (*git.Repository, *git.Commit) { + wikiRepo, err := gitrepo.OpenWikiRepository(ctx, ctx.Repo.Repository) + if err != nil { + if git.IsErrNotExist(err) || err.Error() == "no such file or directory" { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "OpenRepository", err) + } + return nil, nil + } + + commit, err := wikiRepo.GetBranchCommit(ctx.Repo.Repository.GetWikiBranchName()) + if err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetBranchCommit", err) + } + return wikiRepo, nil + } + return wikiRepo, commit +} + +// wikiContentsByEntry returns the contents of the wiki page referenced by the +// given tree entry, encoded with base64. Writes to ctx if an error occurs. +func wikiContentsByEntry(ctx *context.APIContext, entry *git.TreeEntry) string { + blob := entry.Blob() + if blob.Size() > setting.API.DefaultMaxBlobSize { + return "" + } + content, err := blob.GetBlobContentBase64() + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetBlobContentBase64", err) + return "" + } + return content +} + +// wikiContentsByName returns the contents of a wiki page, along with a boolean +// indicating whether the page exists. Writes to ctx if an error occurs. +func wikiContentsByName(ctx *context.APIContext, commit *git.Commit, wikiName wiki_service.WebPath, isSidebarOrFooter bool) (string, string) { + gitFilename := wiki_service.WebPathToGitPath(wikiName) + entry, err := findEntryForFile(commit, gitFilename) + if err != nil { + if git.IsErrNotExist(err) { + if !isSidebarOrFooter { + ctx.NotFound() + } + } else { + ctx.ServerError("findEntryForFile", err) + } + return "", "" + } + return wikiContentsByEntry(ctx, entry), gitFilename +} diff --git a/routers/api/v1/settings/settings.go b/routers/api/v1/settings/settings.go new file mode 100644 index 0000000..c422315 --- /dev/null +++ b/routers/api/v1/settings/settings.go @@ -0,0 +1,86 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package settings + +import ( + "net/http" + + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" +) + +// GetGeneralUISettings returns instance's global settings for ui +func GetGeneralUISettings(ctx *context.APIContext) { + // swagger:operation GET /settings/ui settings getGeneralUISettings + // --- + // summary: Get instance's global settings for ui + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/GeneralUISettings" + ctx.JSON(http.StatusOK, api.GeneralUISettings{ + DefaultTheme: setting.UI.DefaultTheme, + AllowedReactions: setting.UI.Reactions, + CustomEmojis: setting.UI.CustomEmojis, + }) +} + +// GetGeneralAPISettings returns instance's global settings for api +func GetGeneralAPISettings(ctx *context.APIContext) { + // swagger:operation GET /settings/api settings getGeneralAPISettings + // --- + // summary: Get instance's global settings for api + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/GeneralAPISettings" + ctx.JSON(http.StatusOK, api.GeneralAPISettings{ + MaxResponseItems: setting.API.MaxResponseItems, + DefaultPagingNum: setting.API.DefaultPagingNum, + DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage, + DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize, + }) +} + +// GetGeneralRepoSettings returns instance's global settings for repositories +func GetGeneralRepoSettings(ctx *context.APIContext) { + // swagger:operation GET /settings/repository settings getGeneralRepositorySettings + // --- + // summary: Get instance's global settings for repositories + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/GeneralRepoSettings" + ctx.JSON(http.StatusOK, api.GeneralRepoSettings{ + MirrorsDisabled: !setting.Mirror.Enabled, + HTTPGitDisabled: setting.Repository.DisableHTTPGit, + MigrationsDisabled: setting.Repository.DisableMigrations, + StarsDisabled: setting.Repository.DisableStars, + ForksDisabled: setting.Repository.DisableForks, + TimeTrackingDisabled: !setting.Service.EnableTimetracking, + LFSDisabled: !setting.LFS.StartServer, + }) +} + +// GetGeneralAttachmentSettings returns instance's global settings for Attachment +func GetGeneralAttachmentSettings(ctx *context.APIContext) { + // swagger:operation GET /settings/attachment settings getGeneralAttachmentSettings + // --- + // summary: Get instance's global settings for Attachment + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/GeneralAttachmentSettings" + ctx.JSON(http.StatusOK, api.GeneralAttachmentSettings{ + Enabled: setting.Attachment.Enabled, + AllowedTypes: setting.Attachment.AllowedTypes, + MaxFiles: setting.Attachment.MaxFiles, + MaxSize: setting.Attachment.MaxSize, + }) +} diff --git a/routers/api/v1/shared/quota.go b/routers/api/v1/shared/quota.go new file mode 100644 index 0000000..b892df4 --- /dev/null +++ b/routers/api/v1/shared/quota.go @@ -0,0 +1,102 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "net/http" + + quota_model "code.gitea.io/gitea/models/quota" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func GetQuota(ctx *context.APIContext, userID int64) { + used, err := quota_model.GetUsedForUser(ctx, userID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.GetUsedForUser", err) + return + } + + groups, err := quota_model.GetGroupsForUser(ctx, userID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.GetGroupsForUser", err) + return + } + + result := convert.ToQuotaInfo(used, groups, false) + ctx.JSON(http.StatusOK, &result) +} + +func CheckQuota(ctx *context.APIContext, userID int64) { + subjectQuery := ctx.FormTrim("subject") + + subject, err := quota_model.ParseLimitSubject(subjectQuery) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "quota_model.ParseLimitSubject", err) + return + } + + ok, err := quota_model.EvaluateForUser(ctx, userID, subject) + if err != nil { + ctx.Error(http.StatusInternalServerError, "quota_model.EvaluateForUser", err) + return + } + + ctx.JSON(http.StatusOK, &ok) +} + +func ListQuotaAttachments(ctx *context.APIContext, userID int64) { + opts := utils.GetListOptions(ctx) + count, attachments, err := quota_model.GetQuotaAttachmentsForUser(ctx, userID, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetQuotaAttachmentsForUser", err) + return + } + + result, err := convert.ToQuotaUsedAttachmentList(ctx, *attachments) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedAttachmentList", err) + } + + ctx.SetLinkHeader(int(count), opts.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, result) +} + +func ListQuotaPackages(ctx *context.APIContext, userID int64) { + opts := utils.GetListOptions(ctx) + count, packages, err := quota_model.GetQuotaPackagesForUser(ctx, userID, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetQuotaPackagesForUser", err) + return + } + + result, err := convert.ToQuotaUsedPackageList(ctx, *packages) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedPackageList", err) + } + + ctx.SetLinkHeader(int(count), opts.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, result) +} + +func ListQuotaArtifacts(ctx *context.APIContext, userID int64) { + opts := utils.GetListOptions(ctx) + count, artifacts, err := quota_model.GetQuotaArtifactsForUser(ctx, userID, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetQuotaArtifactsForUser", err) + return + } + + result, err := convert.ToQuotaUsedArtifactList(ctx, *artifacts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToQuotaUsedArtifactList", err) + } + + ctx.SetLinkHeader(int(count), opts.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, result) +} diff --git a/routers/api/v1/shared/runners.go b/routers/api/v1/shared/runners.go new file mode 100644 index 0000000..f184786 --- /dev/null +++ b/routers/api/v1/shared/runners.go @@ -0,0 +1,32 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package shared + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +// RegistrationToken is a string used to register a runner with a server +// swagger:response RegistrationToken +type RegistrationToken struct { + Token string `json:"token"` +} + +func GetRegistrationToken(ctx *context.APIContext, ownerID, repoID int64) { + token, err := actions_model.GetLatestRunnerToken(ctx, ownerID, repoID) + if errors.Is(err, util.ErrNotExist) || (token != nil && !token.IsActive) { + token, err = actions_model.NewRunnerToken(ctx, ownerID, repoID) + } + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, RegistrationToken{Token: token.Token}) +} diff --git a/routers/api/v1/swagger/action.go b/routers/api/v1/swagger/action.go new file mode 100644 index 0000000..665f4d0 --- /dev/null +++ b/routers/api/v1/swagger/action.go @@ -0,0 +1,34 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import api "code.gitea.io/gitea/modules/structs" + +// SecretList +// swagger:response SecretList +type swaggerResponseSecretList struct { + // in:body + Body []api.Secret `json:"body"` +} + +// Secret +// swagger:response Secret +type swaggerResponseSecret struct { + // in:body + Body api.Secret `json:"body"` +} + +// ActionVariable +// swagger:response ActionVariable +type swaggerResponseActionVariable struct { + // in:body + Body api.ActionVariable `json:"body"` +} + +// VariableList +// swagger:response VariableList +type swaggerResponseVariableList struct { + // in:body + Body []api.ActionVariable `json:"body"` +} diff --git a/routers/api/v1/swagger/activity.go b/routers/api/v1/swagger/activity.go new file mode 100644 index 0000000..95e1ba9 --- /dev/null +++ b/routers/api/v1/swagger/activity.go @@ -0,0 +1,15 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// ActivityFeedsList +// swagger:response ActivityFeedsList +type swaggerActivityFeedsList struct { + // in:body + Body []api.Activity `json:"body"` +} diff --git a/routers/api/v1/swagger/activitypub.go b/routers/api/v1/swagger/activitypub.go new file mode 100644 index 0000000..9134166 --- /dev/null +++ b/routers/api/v1/swagger/activitypub.go @@ -0,0 +1,15 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// ActivityPub +// swagger:response ActivityPub +type swaggerResponseActivityPub struct { + // in:body + Body api.ActivityPub `json:"body"` +} diff --git a/routers/api/v1/swagger/app.go b/routers/api/v1/swagger/app.go new file mode 100644 index 0000000..6a08b11 --- /dev/null +++ b/routers/api/v1/swagger/app.go @@ -0,0 +1,22 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// OAuth2Application +// swagger:response OAuth2Application +type swaggerResponseOAuth2Application struct { + // in:body + Body api.OAuth2Application `json:"body"` +} + +// AccessToken represents an API access token. +// swagger:response AccessToken +type swaggerResponseAccessToken struct { + // in:body + Body api.AccessToken `json:"body"` +} diff --git a/routers/api/v1/swagger/cron.go b/routers/api/v1/swagger/cron.go new file mode 100644 index 0000000..00cfbe0 --- /dev/null +++ b/routers/api/v1/swagger/cron.go @@ -0,0 +1,15 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// CronList +// swagger:response CronList +type swaggerResponseCronList struct { + // in:body + Body []api.Cron `json:"body"` +} diff --git a/routers/api/v1/swagger/issue.go b/routers/api/v1/swagger/issue.go new file mode 100644 index 0000000..62458a3 --- /dev/null +++ b/routers/api/v1/swagger/issue.go @@ -0,0 +1,127 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// Issue +// swagger:response Issue +type swaggerResponseIssue struct { + // in:body + Body api.Issue `json:"body"` +} + +// IssueList +// swagger:response IssueList +type swaggerResponseIssueList struct { + // in:body + Body []api.Issue `json:"body"` +} + +// Comment +// swagger:response Comment +type swaggerResponseComment struct { + // in:body + Body api.Comment `json:"body"` +} + +// CommentList +// swagger:response CommentList +type swaggerResponseCommentList struct { + // in:body + Body []api.Comment `json:"body"` +} + +// TimelineList +// swagger:response TimelineList +type swaggerResponseTimelineList struct { + // in:body + Body []api.TimelineComment `json:"body"` +} + +// Label +// swagger:response Label +type swaggerResponseLabel struct { + // in:body + Body api.Label `json:"body"` +} + +// LabelList +// swagger:response LabelList +type swaggerResponseLabelList struct { + // in:body + Body []api.Label `json:"body"` +} + +// Milestone +// swagger:response Milestone +type swaggerResponseMilestone struct { + // in:body + Body api.Milestone `json:"body"` +} + +// MilestoneList +// swagger:response MilestoneList +type swaggerResponseMilestoneList struct { + // in:body + Body []api.Milestone `json:"body"` +} + +// TrackedTime +// swagger:response TrackedTime +type swaggerResponseTrackedTime struct { + // in:body + Body api.TrackedTime `json:"body"` +} + +// TrackedTimeList +// swagger:response TrackedTimeList +type swaggerResponseTrackedTimeList struct { + // in:body + Body []api.TrackedTime `json:"body"` +} + +// IssueDeadline +// swagger:response IssueDeadline +type swaggerIssueDeadline struct { + // in:body + Body api.IssueDeadline `json:"body"` +} + +// IssueTemplates +// swagger:response IssueTemplates +type swaggerIssueTemplates struct { + // in:body + Body []api.IssueTemplate `json:"body"` +} + +// StopWatch +// swagger:response StopWatch +type swaggerResponseStopWatch struct { + // in:body + Body api.StopWatch `json:"body"` +} + +// StopWatchList +// swagger:response StopWatchList +type swaggerResponseStopWatchList struct { + // in:body + Body []api.StopWatch `json:"body"` +} + +// Reaction +// swagger:response Reaction +type swaggerReaction struct { + // in:body + Body api.Reaction `json:"body"` +} + +// ReactionList +// swagger:response ReactionList +type swaggerReactionList struct { + // in:body + Body []api.Reaction `json:"body"` +} diff --git a/routers/api/v1/swagger/key.go b/routers/api/v1/swagger/key.go new file mode 100644 index 0000000..8390833 --- /dev/null +++ b/routers/api/v1/swagger/key.go @@ -0,0 +1,50 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// PublicKey +// swagger:response PublicKey +type swaggerResponsePublicKey struct { + // in:body + Body api.PublicKey `json:"body"` +} + +// PublicKeyList +// swagger:response PublicKeyList +type swaggerResponsePublicKeyList struct { + // in:body + Body []api.PublicKey `json:"body"` +} + +// GPGKey +// swagger:response GPGKey +type swaggerResponseGPGKey struct { + // in:body + Body api.GPGKey `json:"body"` +} + +// GPGKeyList +// swagger:response GPGKeyList +type swaggerResponseGPGKeyList struct { + // in:body + Body []api.GPGKey `json:"body"` +} + +// DeployKey +// swagger:response DeployKey +type swaggerResponseDeployKey struct { + // in:body + Body api.DeployKey `json:"body"` +} + +// DeployKeyList +// swagger:response DeployKeyList +type swaggerResponseDeployKeyList struct { + // in:body + Body []api.DeployKey `json:"body"` +} diff --git a/routers/api/v1/swagger/misc.go b/routers/api/v1/swagger/misc.go new file mode 100644 index 0000000..0553eac --- /dev/null +++ b/routers/api/v1/swagger/misc.go @@ -0,0 +1,71 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// ServerVersion +// swagger:response ServerVersion +type swaggerResponseServerVersion struct { + // in:body + Body api.ServerVersion `json:"body"` +} + +// GitignoreTemplateList +// swagger:response GitignoreTemplateList +type swaggerResponseGitignoreTemplateList struct { + // in:body + Body []string `json:"body"` +} + +// GitignoreTemplateInfo +// swagger:response GitignoreTemplateInfo +type swaggerResponseGitignoreTemplateInfo struct { + // in:body + Body api.GitignoreTemplateInfo `json:"body"` +} + +// LicenseTemplateList +// swagger:response LicenseTemplateList +type swaggerResponseLicensesTemplateList struct { + // in:body + Body []api.LicensesTemplateListEntry `json:"body"` +} + +// LicenseTemplateInfo +// swagger:response LicenseTemplateInfo +type swaggerResponseLicenseTemplateInfo struct { + // in:body + Body api.LicenseTemplateInfo `json:"body"` +} + +// StringSlice +// swagger:response StringSlice +type swaggerResponseStringSlice struct { + // in:body + Body []string `json:"body"` +} + +// LabelTemplateList +// swagger:response LabelTemplateList +type swaggerResponseLabelTemplateList struct { + // in:body + Body []string `json:"body"` +} + +// LabelTemplateInfo +// swagger:response LabelTemplateInfo +type swaggerResponseLabelTemplateInfo struct { + // in:body + Body []api.LabelTemplate `json:"body"` +} + +// Boolean +// swagger:response boolean +type swaggerResponseBoolean struct { + // in:body + Body bool `json:"body"` +} diff --git a/routers/api/v1/swagger/nodeinfo.go b/routers/api/v1/swagger/nodeinfo.go new file mode 100644 index 0000000..8650dfa --- /dev/null +++ b/routers/api/v1/swagger/nodeinfo.go @@ -0,0 +1,15 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// NodeInfo +// swagger:response NodeInfo +type swaggerResponseNodeInfo struct { + // in:body + Body api.NodeInfo `json:"body"` +} diff --git a/routers/api/v1/swagger/notify.go b/routers/api/v1/swagger/notify.go new file mode 100644 index 0000000..743d807 --- /dev/null +++ b/routers/api/v1/swagger/notify.go @@ -0,0 +1,29 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// NotificationThread +// swagger:response NotificationThread +type swaggerNotificationThread struct { + // in:body + Body api.NotificationThread `json:"body"` +} + +// NotificationThreadList +// swagger:response NotificationThreadList +type swaggerNotificationThreadList struct { + // in:body + Body []api.NotificationThread `json:"body"` +} + +// Number of unread notifications +// swagger:response NotificationCount +type swaggerNotificationCount struct { + // in:body + Body api.NotificationCount `json:"body"` +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go new file mode 100644 index 0000000..3034b09 --- /dev/null +++ b/routers/api/v1/swagger/options.go @@ -0,0 +1,234 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + ffed "code.gitea.io/gitea/modules/forgefed" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/forms" +) + +// not actually a response, just a hack to get go-swagger to include definitions +// of the various XYZOption structs + +// parameterBodies +// swagger:response parameterBodies +type swaggerParameterBodies struct { + // in:body + ForgeLike ffed.ForgeLike + + // in:body + AddCollaboratorOption api.AddCollaboratorOption + + // in:body + ReplaceFlagsOption api.ReplaceFlagsOption + + // in:body + CreateEmailOption api.CreateEmailOption + // in:body + DeleteEmailOption api.DeleteEmailOption + + // in:body + CreateHookOption api.CreateHookOption + // in:body + EditHookOption api.EditHookOption + + // in:body + EditGitHookOption api.EditGitHookOption + + // in:body + CreateIssueOption api.CreateIssueOption + // in:body + EditIssueOption api.EditIssueOption + // in:body + EditDeadlineOption api.EditDeadlineOption + + // in:body + CreateIssueCommentOption api.CreateIssueCommentOption + // in:body + EditIssueCommentOption api.EditIssueCommentOption + // in:body + IssueMeta api.IssueMeta + + // in:body + IssueLabelsOption api.IssueLabelsOption + + // in:body + DeleteLabelsOption api.DeleteLabelsOption + + // in:body + CreateKeyOption api.CreateKeyOption + + // in:body + RenameUserOption api.RenameUserOption + + // in:body + CreateLabelOption api.CreateLabelOption + // in:body + EditLabelOption api.EditLabelOption + + // in:body + MarkupOption api.MarkupOption + // in:body + MarkdownOption api.MarkdownOption + + // in:body + CreateMilestoneOption api.CreateMilestoneOption + // in:body + EditMilestoneOption api.EditMilestoneOption + + // in:body + CreateOrgOption api.CreateOrgOption + // in:body + EditOrgOption api.EditOrgOption + + // in:body + CreatePullRequestOption api.CreatePullRequestOption + // in:body + EditPullRequestOption api.EditPullRequestOption + // in:body + MergePullRequestOption forms.MergePullRequestForm + + // in:body + CreateReleaseOption api.CreateReleaseOption + // in:body + EditReleaseOption api.EditReleaseOption + + // in:body + CreateRepoOption api.CreateRepoOption + // in:body + EditRepoOption api.EditRepoOption + // in:body + TransferRepoOption api.TransferRepoOption + // in:body + CreateForkOption api.CreateForkOption + // in:body + GenerateRepoOption api.GenerateRepoOption + + // in:body + CreateStatusOption api.CreateStatusOption + + // in:body + CreateTeamOption api.CreateTeamOption + // in:body + EditTeamOption api.EditTeamOption + + // in:body + AddTimeOption api.AddTimeOption + + // in:body + CreateUserOption api.CreateUserOption + + // in:body + EditUserOption api.EditUserOption + + // in:body + EditAttachmentOptions api.EditAttachmentOptions + + // in:body + ChangeFilesOptions api.ChangeFilesOptions + + // in:body + CreateFileOptions api.CreateFileOptions + + // in:body + UpdateFileOptions api.UpdateFileOptions + + // in:body + DeleteFileOptions api.DeleteFileOptions + + // in:body + CommitDateOptions api.CommitDateOptions + + // in:body + RepoTopicOptions api.RepoTopicOptions + + // in:body + EditReactionOption api.EditReactionOption + + // in:body + CreateBranchRepoOption api.CreateBranchRepoOption + + // in:body + CreateBranchProtectionOption api.CreateBranchProtectionOption + + // in:body + EditBranchProtectionOption api.EditBranchProtectionOption + + // in:body + CreateOAuth2ApplicationOptions api.CreateOAuth2ApplicationOptions + + // in:body + CreatePullReviewOptions api.CreatePullReviewOptions + + // in:body + CreatePullReviewComment api.CreatePullReviewComment + + // in:body + CreatePullReviewCommentOptions api.CreatePullReviewCommentOptions + + // in:body + SubmitPullReviewOptions api.SubmitPullReviewOptions + + // in:body + DismissPullReviewOptions api.DismissPullReviewOptions + + // in:body + MigrateRepoOptions api.MigrateRepoOptions + + // in:body + PullReviewRequestOptions api.PullReviewRequestOptions + + // in:body + CreateTagOption api.CreateTagOption + + // in:body + CreateTagProtectionOption api.CreateTagProtectionOption + + // in:body + EditTagProtectionOption api.EditTagProtectionOption + + // in:body + CreateAccessTokenOption api.CreateAccessTokenOption + + // in:body + UserSettingsOptions api.UserSettingsOptions + + // in:body + CreateWikiPageOptions api.CreateWikiPageOptions + + // in:body + CreatePushMirrorOption api.CreatePushMirrorOption + + // in:body + UpdateUserAvatarOptions api.UpdateUserAvatarOption + + // in:body + UpdateRepoAvatarOptions api.UpdateRepoAvatarOption + + // in:body + CreateOrUpdateSecretOption api.CreateOrUpdateSecretOption + + // in:body + CreateVariableOption api.CreateVariableOption + + // in:body + UpdateVariableOption api.UpdateVariableOption + + // in:body + DispatchWorkflowOption api.DispatchWorkflowOption + + // in:body + CreateQuotaGroupOptions api.CreateQuotaGroupOptions + + // in:body + CreateQuotaRuleOptions api.CreateQuotaRuleOptions + + // in:body + EditQuotaRuleOptions api.EditQuotaRuleOptions + + // in:body + SetUserQuotaGroupsOptions api.SetUserQuotaGroupsOptions +} diff --git a/routers/api/v1/swagger/org.go b/routers/api/v1/swagger/org.go new file mode 100644 index 0000000..0105446 --- /dev/null +++ b/routers/api/v1/swagger/org.go @@ -0,0 +1,43 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// Organization +// swagger:response Organization +type swaggerResponseOrganization struct { + // in:body + Body api.Organization `json:"body"` +} + +// OrganizationList +// swagger:response OrganizationList +type swaggerResponseOrganizationList struct { + // in:body + Body []api.Organization `json:"body"` +} + +// Team +// swagger:response Team +type swaggerResponseTeam struct { + // in:body + Body api.Team `json:"body"` +} + +// TeamList +// swagger:response TeamList +type swaggerResponseTeamList struct { + // in:body + Body []api.Team `json:"body"` +} + +// OrganizationPermissions +// swagger:response OrganizationPermissions +type swaggerResponseOrganizationPermissions struct { + // in:body + Body api.OrganizationPermissions `json:"body"` +} diff --git a/routers/api/v1/swagger/package.go b/routers/api/v1/swagger/package.go new file mode 100644 index 0000000..eada12d --- /dev/null +++ b/routers/api/v1/swagger/package.go @@ -0,0 +1,29 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// Package +// swagger:response Package +type swaggerResponsePackage struct { + // in:body + Body api.Package `json:"body"` +} + +// PackageList +// swagger:response PackageList +type swaggerResponsePackageList struct { + // in:body + Body []api.Package `json:"body"` +} + +// PackageFileList +// swagger:response PackageFileList +type swaggerResponsePackageFileList struct { + // in:body + Body []api.PackageFile `json:"body"` +} diff --git a/routers/api/v1/swagger/quota.go b/routers/api/v1/swagger/quota.go new file mode 100644 index 0000000..35e633c --- /dev/null +++ b/routers/api/v1/swagger/quota.go @@ -0,0 +1,64 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// QuotaInfo +// swagger:response QuotaInfo +type swaggerResponseQuotaInfo struct { + // in:body + Body api.QuotaInfo `json:"body"` +} + +// QuotaRuleInfoList +// swagger:response QuotaRuleInfoList +type swaggerResponseQuotaRuleInfoList struct { + // in:body + Body []api.QuotaRuleInfo `json:"body"` +} + +// QuotaRuleInfo +// swagger:response QuotaRuleInfo +type swaggerResponseQuotaRuleInfo struct { + // in:body + Body api.QuotaRuleInfo `json:"body"` +} + +// QuotaUsedAttachmentList +// swagger:response QuotaUsedAttachmentList +type swaggerQuotaUsedAttachmentList struct { + // in:body + Body api.QuotaUsedAttachmentList `json:"body"` +} + +// QuotaUsedPackageList +// swagger:response QuotaUsedPackageList +type swaggerQuotaUsedPackageList struct { + // in:body + Body api.QuotaUsedPackageList `json:"body"` +} + +// QuotaUsedArtifactList +// swagger:response QuotaUsedArtifactList +type swaggerQuotaUsedArtifactList struct { + // in:body + Body api.QuotaUsedArtifactList `json:"body"` +} + +// QuotaGroup +// swagger:response QuotaGroup +type swaggerResponseQuotaGroup struct { + // in:body + Body api.QuotaGroup `json:"body"` +} + +// QuotaGroupList +// swagger:response QuotaGroupList +type swaggerResponseQuotaGroupList struct { + // in:body + Body api.QuotaGroupList `json:"body"` +} diff --git a/routers/api/v1/swagger/repo.go b/routers/api/v1/swagger/repo.go new file mode 100644 index 0000000..ca214b4 --- /dev/null +++ b/routers/api/v1/swagger/repo.go @@ -0,0 +1,450 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// Repository +// swagger:response Repository +type swaggerResponseRepository struct { + // in:body + Body api.Repository `json:"body"` +} + +// RepositoryList +// swagger:response RepositoryList +type swaggerResponseRepositoryList struct { + // in:body + Body []api.Repository `json:"body"` +} + +// Branch +// swagger:response Branch +type swaggerResponseBranch struct { + // in:body + Body api.Branch `json:"body"` +} + +// BranchList +// swagger:response BranchList +type swaggerResponseBranchList struct { + // in:body + Body []api.Branch `json:"body"` +} + +// BranchProtection +// swagger:response BranchProtection +type swaggerResponseBranchProtection struct { + // in:body + Body api.BranchProtection `json:"body"` +} + +// BranchProtectionList +// swagger:response BranchProtectionList +type swaggerResponseBranchProtectionList struct { + // in:body + Body []api.BranchProtection `json:"body"` +} + +// TagList +// swagger:response TagList +type swaggerResponseTagList struct { + // in:body + Body []api.Tag `json:"body"` +} + +// Tag +// swagger:response Tag +type swaggerResponseTag struct { + // in:body + Body api.Tag `json:"body"` +} + +// AnnotatedTag +// swagger:response AnnotatedTag +type swaggerResponseAnnotatedTag struct { + // in:body + Body api.AnnotatedTag `json:"body"` +} + +// TagProtectionList +// swagger:response TagProtectionList +type swaggerResponseTagProtectionList struct { + // in:body + Body []api.TagProtection `json:"body"` +} + +// TagProtection +// swagger:response TagProtection +type swaggerResponseTagProtection struct { + // in:body + Body api.TagProtection `json:"body"` +} + +// Reference +// swagger:response Reference +type swaggerResponseReference struct { + // in:body + Body api.Reference `json:"body"` +} + +// ReferenceList +// swagger:response ReferenceList +type swaggerResponseReferenceList struct { + // in:body + Body []api.Reference `json:"body"` +} + +// Hook +// swagger:response Hook +type swaggerResponseHook struct { + // in:body + Body api.Hook `json:"body"` +} + +// HookList +// swagger:response HookList +type swaggerResponseHookList struct { + // in:body + Body []api.Hook `json:"body"` +} + +// GitHook +// swagger:response GitHook +type swaggerResponseGitHook struct { + // in:body + Body api.GitHook `json:"body"` +} + +// GitHookList +// swagger:response GitHookList +type swaggerResponseGitHookList struct { + // in:body + Body []api.GitHook `json:"body"` +} + +// Release +// swagger:response Release +type swaggerResponseRelease struct { + // in:body + Body api.Release `json:"body"` +} + +// ReleaseList +// swagger:response ReleaseList +type swaggerResponseReleaseList struct { + // in:body + Body []api.Release `json:"body"` +} + +// PullRequest +// swagger:response PullRequest +type swaggerResponsePullRequest struct { + // in:body + Body api.PullRequest `json:"body"` +} + +// PullRequestList +// swagger:response PullRequestList +type swaggerResponsePullRequestList struct { + // in:body + Body []api.PullRequest `json:"body"` +} + +// PullReview +// swagger:response PullReview +type swaggerResponsePullReview struct { + // in:body + Body api.PullReview `json:"body"` +} + +// PullReviewList +// swagger:response PullReviewList +type swaggerResponsePullReviewList struct { + // in:body + Body []api.PullReview `json:"body"` +} + +// PullComment +// swagger:response PullReviewComment +type swaggerPullReviewComment struct { + // in:body + Body api.PullReviewComment `json:"body"` +} + +// PullCommentList +// swagger:response PullReviewCommentList +type swaggerResponsePullReviewCommentList struct { + // in:body + Body []api.PullReviewComment `json:"body"` +} + +// CommitStatus +// swagger:response CommitStatus +type swaggerResponseStatus struct { + // in:body + Body api.CommitStatus `json:"body"` +} + +// CommitStatusList +// swagger:response CommitStatusList +type swaggerResponseCommitStatusList struct { + // in:body + Body []api.CommitStatus `json:"body"` +} + +// WatchInfo +// swagger:response WatchInfo +type swaggerResponseWatchInfo struct { + // in:body + Body api.WatchInfo `json:"body"` +} + +// SearchResults +// swagger:response SearchResults +type swaggerResponseSearchResults struct { + // in:body + Body api.SearchResults `json:"body"` +} + +// AttachmentList +// swagger:response AttachmentList +type swaggerResponseAttachmentList struct { + // in: body + Body []api.Attachment `json:"body"` +} + +// Attachment +// swagger:response Attachment +type swaggerResponseAttachment struct { + // in: body + Body api.Attachment `json:"body"` +} + +// GitTreeResponse +// swagger:response GitTreeResponse +type swaggerGitTreeResponse struct { + // in: body + Body api.GitTreeResponse `json:"body"` +} + +// GitBlobResponse +// swagger:response GitBlobResponse +type swaggerGitBlobResponse struct { + // in: body + Body api.GitBlobResponse `json:"body"` +} + +// Commit +// swagger:response Commit +type swaggerCommit struct { + // in: body + Body api.Commit `json:"body"` +} + +// CommitList +// swagger:response CommitList +type swaggerCommitList struct { + // The current page + Page int `json:"X-Page"` + + // Commits per page + PerPage int `json:"X-PerPage"` + + // Total commit count + Total int `json:"X-Total"` + + // Total number of pages + PageCount int `json:"X-PageCount"` + + // True if there is another page + HasMore bool `json:"X-HasMore"` + + // in: body + Body []api.Commit `json:"body"` +} + +// ChangedFileList +// swagger:response ChangedFileList +type swaggerChangedFileList struct { + // The current page + Page int `json:"X-Page"` + + // Commits per page + PerPage int `json:"X-PerPage"` + + // Total commit count + Total int `json:"X-Total-Count"` + + // Total number of pages + PageCount int `json:"X-PageCount"` + + // True if there is another page + HasMore bool `json:"X-HasMore"` + + // in: body + Body []api.ChangedFile `json:"body"` +} + +// Note +// swagger:response Note +type swaggerNote struct { + // in: body + Body api.Note `json:"body"` +} + +// EmptyRepository +// swagger:response EmptyRepository +type swaggerEmptyRepository struct { + // in: body + Body api.APIError `json:"body"` +} + +// FileResponse +// swagger:response FileResponse +type swaggerFileResponse struct { + // in: body + Body api.FileResponse `json:"body"` +} + +// FilesResponse +// swagger:response FilesResponse +type swaggerFilesResponse struct { + // in: body + Body api.FilesResponse `json:"body"` +} + +// ContentsResponse +// swagger:response ContentsResponse +type swaggerContentsResponse struct { + // in: body + Body api.ContentsResponse `json:"body"` +} + +// ContentsListResponse +// swagger:response ContentsListResponse +type swaggerContentsListResponse struct { + // in:body + Body []api.ContentsResponse `json:"body"` +} + +// FileDeleteResponse +// swagger:response FileDeleteResponse +type swaggerFileDeleteResponse struct { + // in: body + Body api.FileDeleteResponse `json:"body"` +} + +// TopicListResponse +// swagger:response TopicListResponse +type swaggerTopicListResponse struct { + // in: body + Body []api.TopicResponse `json:"body"` +} + +// TopicNames +// swagger:response TopicNames +type swaggerTopicNames struct { + // in: body + Body api.TopicName `json:"body"` +} + +// LanguageStatistics +// swagger:response LanguageStatistics +type swaggerLanguageStatistics struct { + // in: body + Body map[string]int64 `json:"body"` +} + +// CombinedStatus +// swagger:response CombinedStatus +type swaggerCombinedStatus struct { + // in: body + Body api.CombinedStatus `json:"body"` +} + +// WikiPageList +// swagger:response WikiPageList +type swaggerWikiPageList struct { + // in:body + Body []api.WikiPageMetaData `json:"body"` +} + +// WikiPage +// swagger:response WikiPage +type swaggerWikiPage struct { + // in:body + Body api.WikiPage `json:"body"` +} + +// WikiCommitList +// swagger:response WikiCommitList +type swaggerWikiCommitList struct { + // in:body + Body api.WikiCommitList `json:"body"` +} + +// PushMirror +// swagger:response PushMirror +type swaggerPushMirror struct { + // in:body + Body api.PushMirror `json:"body"` +} + +// PushMirrorList +// swagger:response PushMirrorList +type swaggerPushMirrorList struct { + // in:body + Body []api.PushMirror `json:"body"` +} + +// RepoCollaboratorPermission +// swagger:response RepoCollaboratorPermission +type swaggerRepoCollaboratorPermission struct { + // in:body + Body api.RepoCollaboratorPermission `json:"body"` +} + +// RepoIssueConfig +// swagger:response RepoIssueConfig +type swaggerRepoIssueConfig struct { + // in:body + Body api.IssueConfig `json:"body"` +} + +// RepoIssueConfigValidation +// swagger:response RepoIssueConfigValidation +type swaggerRepoIssueConfigValidation struct { + // in:body + Body api.IssueConfigValidation `json:"body"` +} + +// RepoNewIssuePinsAllowed +// swagger:response RepoNewIssuePinsAllowed +type swaggerRepoNewIssuePinsAllowed struct { + // in:body + Body api.NewIssuePinsAllowed `json:"body"` +} + +// BlockedUserList +// swagger:response BlockedUserList +type swaggerBlockedUserList struct { + // in:body + Body []api.BlockedUser `json:"body"` +} + +// TasksList +// swagger:response TasksList +type swaggerRepoTasksList struct { + // in:body + Body api.ActionTaskResponse `json:"body"` +} + +// swagger:response Compare +type swaggerCompare struct { + // in:body + Body api.Compare `json:"body"` +} diff --git a/routers/api/v1/swagger/settings.go b/routers/api/v1/swagger/settings.go new file mode 100644 index 0000000..a946669 --- /dev/null +++ b/routers/api/v1/swagger/settings.go @@ -0,0 +1,34 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import api "code.gitea.io/gitea/modules/structs" + +// GeneralRepoSettings +// swagger:response GeneralRepoSettings +type swaggerResponseGeneralRepoSettings struct { + // in:body + Body api.GeneralRepoSettings `json:"body"` +} + +// GeneralUISettings +// swagger:response GeneralUISettings +type swaggerResponseGeneralUISettings struct { + // in:body + Body api.GeneralUISettings `json:"body"` +} + +// GeneralAPISettings +// swagger:response GeneralAPISettings +type swaggerResponseGeneralAPISettings struct { + // in:body + Body api.GeneralAPISettings `json:"body"` +} + +// GeneralAttachmentSettings +// swagger:response GeneralAttachmentSettings +type swaggerResponseGeneralAttachmentSettings struct { + // in:body + Body api.GeneralAttachmentSettings `json:"body"` +} diff --git a/routers/api/v1/swagger/user.go b/routers/api/v1/swagger/user.go new file mode 100644 index 0000000..37e2866 --- /dev/null +++ b/routers/api/v1/swagger/user.go @@ -0,0 +1,50 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package swagger + +import ( + activities_model "code.gitea.io/gitea/models/activities" + api "code.gitea.io/gitea/modules/structs" +) + +// User +// swagger:response User +type swaggerResponseUser struct { + // in:body + Body api.User `json:"body"` +} + +// UserList +// swagger:response UserList +type swaggerResponseUserList struct { + // in:body + Body []api.User `json:"body"` +} + +// EmailList +// swagger:response EmailList +type swaggerResponseEmailList struct { + // in:body + Body []api.Email `json:"body"` +} + +// swagger:model EditUserOption +type swaggerModelEditUserOption struct { + // in:body + Options api.EditUserOption +} + +// UserHeatmapData +// swagger:response UserHeatmapData +type swaggerResponseUserHeatmapData struct { + // in:body + Body []activities_model.UserHeatmapData `json:"body"` +} + +// UserSettings +// swagger:response UserSettings +type swaggerResponseUserSettings struct { + // in:body + Body api.UserSettings `json:"body"` +} diff --git a/routers/api/v1/user/action.go b/routers/api/v1/user/action.go new file mode 100644 index 0000000..bf78c2c --- /dev/null +++ b/routers/api/v1/user/action.go @@ -0,0 +1,353 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + "net/http" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + 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/utils" + actions_service "code.gitea.io/gitea/services/actions" + "code.gitea.io/gitea/services/context" + secret_service "code.gitea.io/gitea/services/secrets" +) + +// create or update one secret of the user scope +func CreateOrUpdateSecret(ctx *context.APIContext) { + // swagger:operation PUT /user/actions/secrets/{secretname} user updateUserSecret + // --- + // summary: Create or Update a secret value in a user scope + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateOrUpdateSecretOption" + // responses: + // "201": + // description: response when creating a secret + // "204": + // description: response when updating a secret + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateOrUpdateSecretOption) + + _, created, err := secret_service.CreateOrUpdateSecret(ctx, ctx.Doer.ID, 0, ctx.Params("secretname"), opt.Data) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateOrUpdateSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "CreateOrUpdateSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateOrUpdateSecret", err) + } + return + } + + if created { + ctx.Status(http.StatusCreated) + } else { + ctx.Status(http.StatusNoContent) + } +} + +// DeleteSecret delete one secret of the user scope +func DeleteSecret(ctx *context.APIContext) { + // swagger:operation DELETE /user/actions/secrets/{secretname} user deleteUserSecret + // --- + // summary: Delete a secret in a user scope + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: secretname + // in: path + // description: name of the secret + // type: string + // required: true + // responses: + // "204": + // description: delete one secret of the user + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + err := secret_service.DeleteSecretByName(ctx, ctx.Doer.ID, 0, ctx.Params("secretname")) + if err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteSecret", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteSecret", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteSecret", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateVariable create a user-level variable +func CreateVariable(ctx *context.APIContext) { + // swagger:operation POST /user/actions/variables/{variablename} user createUserVariable + // --- + // summary: Create a user-level variable + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateVariableOption" + // responses: + // "201": + // description: response when creating a variable + // "204": + // description: response when creating a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.CreateVariableOption) + + ownerID := ctx.Doer.ID + variableName := ctx.Params("variablename") + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ownerID, + Name: variableName, + }) + if err != nil && !errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + return + } + if v != nil && v.ID > 0 { + ctx.Error(http.StatusConflict, "VariableNameAlreadyExists", util.NewAlreadyExistErrorf("variable name %s already exists", variableName)) + return + } + + if _, err := actions_service.CreateVariable(ctx, ownerID, 0, variableName, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "CreateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateVariable update a user-level variable which is created by current doer +func UpdateVariable(ctx *context.APIContext) { + // swagger:operation PUT /user/actions/variables/{variablename} user updateUserVariable + // --- + // summary: Update a user-level variable which is created by current doer + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateVariableOption" + // responses: + // "201": + // description: response when updating a variable + // "204": + // description: response when updating a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + opt := web.GetForm(ctx).(*api.UpdateVariableOption) + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + if opt.Name == "" { + opt.Name = ctx.Params("variablename") + } + if _, err := actions_service.UpdateVariable(ctx, v.ID, opt.Name, opt.Value); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "UpdateVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateVariable", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteVariable delete a user-level variable which is created by current doer +func DeleteVariable(ctx *context.APIContext) { + // swagger:operation DELETE /user/actions/variables/{variablename} user deleteUserVariable + // --- + // summary: Delete a user-level variable which is created by current doer + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "201": + // description: response when deleting a variable + // "204": + // description: response when deleting a variable + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + if err := actions_service.DeleteVariableByName(ctx, ctx.Doer.ID, 0, ctx.Params("variablename")); err != nil { + if errors.Is(err, util.ErrInvalidArgument) { + ctx.Error(http.StatusBadRequest, "DeleteVariableByName", err) + } else if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "DeleteVariableByName", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteVariableByName", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// GetVariable get a user-level variable which is created by current doer +func GetVariable(ctx *context.APIContext) { + // swagger:operation GET /user/actions/variables/{variablename} user getUserVariable + // --- + // summary: Get a user-level variable which is created by current doer + // produces: + // - application/json + // parameters: + // - name: variablename + // in: path + // description: name of the variable + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActionVariable" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + v, err := actions_service.GetVariable(ctx, actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + Name: ctx.Params("variablename"), + }) + if err != nil { + if errors.Is(err, util.ErrNotExist) { + ctx.Error(http.StatusNotFound, "GetVariable", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetVariable", err) + } + return + } + + variable := &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + + ctx.JSON(http.StatusOK, variable) +} + +// ListVariables list user-level variables +func ListVariables(ctx *context.APIContext) { + // swagger:operation GET /user/actions/variables user getUserVariablesList + // --- + // summary: Get the user-level list of variables which is created by current doer + // produces: + // - application/json + // parameters: + // - 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/VariableList" + // "400": + // "$ref": "#/responses/error" + // "404": + // "$ref": "#/responses/notFound" + + vars, count, err := db.FindAndCount[actions_model.ActionVariable](ctx, &actions_model.FindVariablesOpts{ + OwnerID: ctx.Doer.ID, + ListOptions: utils.GetListOptions(ctx), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindVariables", err) + return + } + + variables := make([]*api.ActionVariable, len(vars)) + for i, v := range vars { + variables[i] = &api.ActionVariable{ + OwnerID: v.OwnerID, + RepoID: v.RepoID, + Name: v.Name, + Data: v.Data, + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, variables) +} diff --git a/routers/api/v1/user/app.go b/routers/api/v1/user/app.go new file mode 100644 index 0000000..d5b20f7 --- /dev/null +++ b/routers/api/v1/user/app.go @@ -0,0 +1,410 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// ListAccessTokens list all the access tokens +func ListAccessTokens(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/tokens user userGetTokens + // --- + // summary: List the authenticated user's access tokens + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - 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/AccessTokenList" + // "403": + // "$ref": "#/responses/forbidden" + + opts := auth_model.ListAccessTokensOptions{UserID: ctx.ContextUser.ID, ListOptions: utils.GetListOptions(ctx)} + + tokens, count, err := db.FindAndCount[auth_model.AccessToken](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiTokens := make([]*api.AccessToken, len(tokens)) + for i := range tokens { + apiTokens[i] = &api.AccessToken{ + ID: tokens[i].ID, + Name: tokens[i].Name, + TokenLastEight: tokens[i].TokenLastEight, + Scopes: tokens[i].Scope.StringSlice(), + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiTokens) +} + +// CreateAccessToken create access tokens +func CreateAccessToken(ctx *context.APIContext) { + // swagger:operation POST /users/{username}/tokens user userCreateToken + // --- + // summary: Create an access token + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // required: true + // type: string + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateAccessTokenOption" + // responses: + // "201": + // "$ref": "#/responses/AccessToken" + // "400": + // "$ref": "#/responses/error" + // "403": + // "$ref": "#/responses/forbidden" + + form := web.GetForm(ctx).(*api.CreateAccessTokenOption) + + t := &auth_model.AccessToken{ + UID: ctx.ContextUser.ID, + Name: form.Name, + } + + exist, err := auth_model.AccessTokenByNameExists(ctx, t) + if err != nil { + ctx.InternalServerError(err) + return + } + if exist { + ctx.Error(http.StatusBadRequest, "AccessTokenByNameExists", errors.New("access token name has been used already")) + return + } + + scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize() + if err != nil { + ctx.Error(http.StatusBadRequest, "AccessTokenScope.Normalize", fmt.Errorf("invalid access token scope provided: %w", err)) + return + } + if scope == "" { + ctx.Error(http.StatusBadRequest, "AccessTokenScope", "access token must have a scope") + return + } + t.Scope = scope + + if err := auth_model.NewAccessToken(ctx, t); err != nil { + ctx.Error(http.StatusInternalServerError, "NewAccessToken", err) + return + } + ctx.JSON(http.StatusCreated, &api.AccessToken{ + Name: t.Name, + Token: t.Token, + ID: t.ID, + TokenLastEight: t.TokenLastEight, + Scopes: t.Scope.StringSlice(), + }) +} + +// DeleteAccessToken delete access tokens +func DeleteAccessToken(ctx *context.APIContext) { + // swagger:operation DELETE /users/{username}/tokens/{token} user userDeleteAccessToken + // --- + // summary: delete an access token + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: token + // in: path + // description: token to be deleted, identified by ID and if not available by name + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/error" + + token := ctx.Params(":id") + tokenID, _ := strconv.ParseInt(token, 0, 64) + + if tokenID == 0 { + tokens, err := db.Find[auth_model.AccessToken](ctx, auth_model.ListAccessTokensOptions{ + Name: token, + UserID: ctx.ContextUser.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListAccessTokens", err) + return + } + + switch len(tokens) { + case 0: + ctx.NotFound() + return + case 1: + tokenID = tokens[0].ID + default: + ctx.Error(http.StatusUnprocessableEntity, "DeleteAccessTokenByID", fmt.Errorf("multiple matches for token name '%s'", token)) + return + } + } + if tokenID == 0 { + ctx.Error(http.StatusInternalServerError, "Invalid TokenID", nil) + return + } + + if err := auth_model.DeleteAccessTokenByID(ctx, tokenID, ctx.ContextUser.ID); err != nil { + if auth_model.IsErrAccessTokenNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "DeleteAccessTokenByID", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// CreateOauth2Application is the handler to create a new OAuth2 Application for the authenticated user +func CreateOauth2Application(ctx *context.APIContext) { + // swagger:operation POST /user/applications/oauth2 user userCreateOAuth2Application + // --- + // summary: creates a new OAuth2 application + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateOAuth2ApplicationOptions" + // responses: + // "201": + // "$ref": "#/responses/OAuth2Application" + // "400": + // "$ref": "#/responses/error" + + data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions) + + app, err := auth_model.CreateOAuth2Application(ctx, auth_model.CreateOAuth2ApplicationOptions{ + Name: data.Name, + UserID: ctx.Doer.ID, + RedirectURIs: data.RedirectURIs, + ConfidentialClient: data.ConfidentialClient, + }) + if err != nil { + ctx.Error(http.StatusBadRequest, "", "error creating oauth2 application") + return + } + secret, err := app.GenerateClientSecret(ctx) + if err != nil { + ctx.Error(http.StatusBadRequest, "", "error creating application secret") + return + } + app.ClientSecret = secret + + ctx.JSON(http.StatusCreated, convert.ToOAuth2Application(app)) +} + +// ListOauth2Applications list all the Oauth2 application +func ListOauth2Applications(ctx *context.APIContext) { + // swagger:operation GET /user/applications/oauth2 user userGetOAuth2Applications + // --- + // summary: List the authenticated user's oauth2 applications + // produces: + // - application/json + // parameters: + // - 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/OAuth2ApplicationList" + + apps, total, err := db.FindAndCount[auth_model.OAuth2Application](ctx, auth_model.FindOAuth2ApplicationsOptions{ + ListOptions: utils.GetListOptions(ctx), + OwnerID: ctx.Doer.ID, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListOAuth2Applications", err) + return + } + + apiApps := make([]*api.OAuth2Application, len(apps)) + for i := range apps { + apiApps[i] = convert.ToOAuth2Application(apps[i]) + apiApps[i].ClientSecret = "" // Hide secret on application list + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, &apiApps) +} + +// DeleteOauth2Application delete OAuth2 Application +func DeleteOauth2Application(ctx *context.APIContext) { + // swagger:operation DELETE /user/applications/oauth2/{id} user userDeleteOAuth2Application + // --- + // summary: delete an OAuth2 Application + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: token to be deleted + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + appID := ctx.ParamsInt64(":id") + if err := auth_model.DeleteOAuth2Application(ctx, appID, ctx.Doer.ID); err != nil { + if auth_model.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "DeleteOauth2ApplicationByID", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// GetOauth2Application get OAuth2 Application +func GetOauth2Application(ctx *context.APIContext) { + // swagger:operation GET /user/applications/oauth2/{id} user userGetOAuth2Application + // --- + // summary: get an OAuth2 Application + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: Application ID to be found + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/OAuth2Application" + // "404": + // "$ref": "#/responses/notFound" + appID := ctx.ParamsInt64(":id") + app, err := auth_model.GetOAuth2ApplicationByID(ctx, appID) + if err != nil { + if auth_model.IsErrOauthClientIDInvalid(err) || auth_model.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetOauth2ApplicationByID", err) + } + return + } + if app.UID != ctx.Doer.ID { + ctx.NotFound() + return + } + + app.ClientSecret = "" + + ctx.JSON(http.StatusOK, convert.ToOAuth2Application(app)) +} + +// UpdateOauth2Application update OAuth2 Application +func UpdateOauth2Application(ctx *context.APIContext) { + // swagger:operation PATCH /user/applications/oauth2/{id} user userUpdateOAuth2Application + // --- + // summary: update an OAuth2 Application, this includes regenerating the client secret + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: application to be updated + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateOAuth2ApplicationOptions" + // responses: + // "200": + // "$ref": "#/responses/OAuth2Application" + // "404": + // "$ref": "#/responses/notFound" + appID := ctx.ParamsInt64(":id") + + data := web.GetForm(ctx).(*api.CreateOAuth2ApplicationOptions) + + app, err := auth_model.UpdateOAuth2Application(ctx, auth_model.UpdateOAuth2ApplicationOptions{ + Name: data.Name, + UserID: ctx.Doer.ID, + ID: appID, + RedirectURIs: data.RedirectURIs, + ConfidentialClient: data.ConfidentialClient, + }) + if err != nil { + if auth_model.IsErrOauthClientIDInvalid(err) || auth_model.IsErrOAuthApplicationNotFound(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "UpdateOauth2ApplicationByID", err) + } + return + } + app.ClientSecret, err = app.GenerateClientSecret(ctx) + if err != nil { + ctx.Error(http.StatusBadRequest, "", "error updating application secret") + return + } + + ctx.JSON(http.StatusOK, convert.ToOAuth2Application(app)) +} diff --git a/routers/api/v1/user/avatar.go b/routers/api/v1/user/avatar.go new file mode 100644 index 0000000..30ccb63 --- /dev/null +++ b/routers/api/v1/user/avatar.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "encoding/base64" + "net/http" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +// UpdateAvatar updates the Avatar of an User +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /user/avatar user userUpdateAvatar + // --- + // summary: Update Avatar + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateUserAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + form := web.GetForm(ctx).(*api.UpdateUserAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = user_service.UploadAvatar(ctx, ctx.Doer, content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteAvatar deletes the Avatar of an User +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /user/avatar user userDeleteAvatar + // --- + // summary: Delete Avatar + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + err := user_service.DeleteAvatar(ctx, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/user/email.go b/routers/api/v1/user/email.go new file mode 100644 index 0000000..33aa851 --- /dev/null +++ b/routers/api/v1/user/email.go @@ -0,0 +1,132 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + user_service "code.gitea.io/gitea/services/user" +) + +// ListEmails list all of the authenticated user's email addresses +// see https://github.com/gogits/go-gogs-client/wiki/Users-Emails#list-email-addresses-for-a-user +func ListEmails(ctx *context.APIContext) { + // swagger:operation GET /user/emails user userListEmails + // --- + // summary: List the authenticated user's email addresses + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/EmailList" + + emails, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetEmailAddresses", err) + return + } + apiEmails := make([]*api.Email, len(emails)) + for i := range emails { + apiEmails[i] = convert.ToEmail(emails[i]) + } + ctx.JSON(http.StatusOK, &apiEmails) +} + +// AddEmail add an email address +func AddEmail(ctx *context.APIContext) { + // swagger:operation POST /user/emails user userAddEmail + // --- + // summary: Add email addresses + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateEmailOption" + // responses: + // '201': + // "$ref": "#/responses/EmailList" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateEmailOption) + if len(form.Emails) == 0 { + ctx.Error(http.StatusUnprocessableEntity, "", "Email list empty") + return + } + + if err := user_service.AddEmailAddresses(ctx, ctx.Doer, form.Emails); err != nil { + if user_model.IsErrEmailAlreadyUsed(err) { + ctx.Error(http.StatusUnprocessableEntity, "", "Email address has been used: "+err.(user_model.ErrEmailAlreadyUsed).Email) + } else if user_model.IsErrEmailCharIsNotSupported(err) || user_model.IsErrEmailInvalid(err) { + email := "" + if typedError, ok := err.(user_model.ErrEmailInvalid); ok { + email = typedError.Email + } + if typedError, ok := err.(user_model.ErrEmailCharIsNotSupported); ok { + email = typedError.Email + } + + errMsg := fmt.Sprintf("Email address %q invalid", email) + ctx.Error(http.StatusUnprocessableEntity, "", errMsg) + } else { + ctx.Error(http.StatusInternalServerError, "AddEmailAddresses", err) + } + return + } + + emails, err := user_model.GetEmailAddresses(ctx, ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetEmailAddresses", err) + return + } + + apiEmails := make([]*api.Email, 0, len(emails)) + for _, email := range emails { + apiEmails = append(apiEmails, convert.ToEmail(email)) + } + ctx.JSON(http.StatusCreated, apiEmails) +} + +// DeleteEmail delete email +func DeleteEmail(ctx *context.APIContext) { + // swagger:operation DELETE /user/emails user userDeleteEmail + // --- + // summary: Delete email addresses + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteEmailOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.DeleteEmailOption) + if len(form.Emails) == 0 { + ctx.Status(http.StatusNoContent) + return + } + + if err := user_service.DeleteEmailAddresses(ctx, ctx.Doer, form.Emails); err != nil { + if user_model.IsErrEmailAddressNotExist(err) { + ctx.Error(http.StatusNotFound, "DeleteEmailAddresses", err) + } else { + ctx.Error(http.StatusInternalServerError, "DeleteEmailAddresses", err) + } + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go new file mode 100644 index 0000000..1ba7346 --- /dev/null +++ b/routers/api/v1/user/follower.go @@ -0,0 +1,263 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package user + +import ( + "errors" + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) { + apiUsers := make([]*api.User, len(users)) + for i := range users { + apiUsers[i] = convert.ToUser(ctx, users[i], ctx.Doer) + } + ctx.JSON(http.StatusOK, &apiUsers) +} + +func listUserFollowers(ctx *context.APIContext, u *user_model.User) { + users, count, err := user_model.GetUserFollowers(ctx, u, ctx.Doer, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserFollowers", err) + return + } + + ctx.SetTotalCountHeader(count) + responseAPIUsers(ctx, users) +} + +// ListMyFollowers list the authenticated user's followers +func ListMyFollowers(ctx *context.APIContext) { + // swagger:operation GET /user/followers user userCurrentListFollowers + // --- + // summary: List the authenticated user's followers + // parameters: + // - 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/UserList" + + listUserFollowers(ctx, ctx.Doer) +} + +// ListFollowers list the given user's followers +func ListFollowers(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/followers user userListFollowers + // --- + // summary: List the given user's followers + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + listUserFollowers(ctx, ctx.ContextUser) +} + +func listUserFollowing(ctx *context.APIContext, u *user_model.User) { + users, count, err := user_model.GetUserFollowing(ctx, u, ctx.Doer, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserFollowing", err) + return + } + + ctx.SetTotalCountHeader(count) + responseAPIUsers(ctx, users) +} + +// ListMyFollowing list the users that the authenticated user is following +func ListMyFollowing(ctx *context.APIContext) { + // swagger:operation GET /user/following user userCurrentListFollowing + // --- + // summary: List the users that the authenticated user is following + // parameters: + // - 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/UserList" + + listUserFollowing(ctx, ctx.Doer) +} + +// ListFollowing list the users that the given user is following +func ListFollowing(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/following user userListFollowing + // --- + // summary: List the users that the given user is following + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - 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/UserList" + // "404": + // "$ref": "#/responses/notFound" + + listUserFollowing(ctx, ctx.ContextUser) +} + +func checkUserFollowing(ctx *context.APIContext, u *user_model.User, followID int64) { + if user_model.IsFollowing(ctx, u.ID, followID) { + ctx.Status(http.StatusNoContent) + } else { + ctx.NotFound() + } +} + +// CheckMyFollowing whether the given user is followed by the authenticated user +func CheckMyFollowing(ctx *context.APIContext) { + // swagger:operation GET /user/following/{username} user userCurrentCheckFollowing + // --- + // summary: Check whether a user is followed by the authenticated user + // parameters: + // - name: username + // in: path + // description: username of followed user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + checkUserFollowing(ctx, ctx.Doer, ctx.ContextUser.ID) +} + +// CheckFollowing check if one user is following another user +func CheckFollowing(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/following/{target} user userCheckFollowing + // --- + // summary: Check if one user is following another user + // parameters: + // - name: username + // in: path + // description: username of following user + // type: string + // required: true + // - name: target + // in: path + // description: username of followed user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + target := GetUserByParamsName(ctx, ":target") + if ctx.Written() { + return + } + checkUserFollowing(ctx, ctx.ContextUser, target.ID) +} + +// Follow follow a user +func Follow(ctx *context.APIContext) { + // swagger:operation PUT /user/following/{username} user userCurrentPutFollow + // --- + // summary: Follow a user + // parameters: + // - name: username + // in: path + // description: username of user to follow + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "403": + // "$ref": "#/responses/forbidden" + + if err := user_model.FollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + if errors.Is(err, user_model.ErrBlockedByUser) { + ctx.Error(http.StatusForbidden, "BlockedByUser", err) + return + } + ctx.Error(http.StatusInternalServerError, "FollowUser", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// Unfollow unfollow a user +func Unfollow(ctx *context.APIContext) { + // swagger:operation DELETE /user/following/{username} user userCurrentDeleteFollow + // --- + // summary: Unfollow a user + // parameters: + // - name: username + // in: path + // description: username of user to unfollow + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + if err := user_model.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + ctx.Error(http.StatusInternalServerError, "UnfollowUser", err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/user/gpg_key.go b/routers/api/v1/user/gpg_key.go new file mode 100644 index 0000000..5a2f995 --- /dev/null +++ b/routers/api/v1/user/gpg_key.go @@ -0,0 +1,311 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + "net/http" + "strings" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +func listGPGKeys(ctx *context.APIContext, uid int64, listOptions db.ListOptions) { + keys, total, err := db.FindAndCount[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + ListOptions: listOptions, + OwnerID: uid, + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListGPGKeys", err) + return + } + + if err := asymkey_model.GPGKeyList(keys).LoadSubKeys(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "ListGPGKeys", err) + return + } + + apiKeys := make([]*api.GPGKey, len(keys)) + for i := range keys { + apiKeys[i] = convert.ToGPGKey(keys[i]) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, &apiKeys) +} + +// ListGPGKeys get the GPG key list of a user +func ListGPGKeys(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/gpg_keys user userListGPGKeys + // --- + // summary: List the given user's GPG keys + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - 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/GPGKeyList" + // "404": + // "$ref": "#/responses/notFound" + + listGPGKeys(ctx, ctx.ContextUser.ID, utils.GetListOptions(ctx)) +} + +// ListMyGPGKeys get the GPG key list of the authenticated user +func ListMyGPGKeys(ctx *context.APIContext) { + // swagger:operation GET /user/gpg_keys user userCurrentListGPGKeys + // --- + // summary: List the authenticated user's GPG keys + // parameters: + // - 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/GPGKeyList" + + listGPGKeys(ctx, ctx.Doer.ID, utils.GetListOptions(ctx)) +} + +// GetGPGKey get the GPG key based on a id +func GetGPGKey(ctx *context.APIContext) { + // swagger:operation GET /user/gpg_keys/{id} user userCurrentGetGPGKey + // --- + // summary: Get a GPG key + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of key to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/GPGKey" + // "404": + // "$ref": "#/responses/notFound" + + key, err := asymkey_model.GetGPGKeyForUserByID(ctx, ctx.Doer.ID, ctx.ParamsInt64(":id")) + if err != nil { + if asymkey_model.IsErrGPGKeyNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetGPGKeyByID", err) + } + return + } + if err := key.LoadSubKeys(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadSubKeys", err) + return + } + ctx.JSON(http.StatusOK, convert.ToGPGKey(key)) +} + +// CreateUserGPGKey creates new GPG key to given user by ID. +func CreateUserGPGKey(ctx *context.APIContext, form api.CreateGPGKeyOption, uid int64) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + + token := asymkey_model.VerificationToken(ctx.Doer, 1) + lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) + + keys, err := asymkey_model.AddGPGKey(ctx, uid, form.ArmoredKey, token, form.Signature) + if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { + keys, err = asymkey_model.AddGPGKey(ctx, uid, form.ArmoredKey, lastToken, form.Signature) + } + if err != nil { + HandleAddGPGKeyError(ctx, err, token) + return + } + ctx.JSON(http.StatusCreated, convert.ToGPGKey(keys[0])) +} + +// GetVerificationToken returns the current token to be signed for this user +func GetVerificationToken(ctx *context.APIContext) { + // swagger:operation GET /user/gpg_key_token user getVerificationToken + // --- + // summary: Get a Token to verify + // produces: + // - text/plain + // parameters: + // responses: + // "200": + // "$ref": "#/responses/string" + // "404": + // "$ref": "#/responses/notFound" + + token := asymkey_model.VerificationToken(ctx.Doer, 1) + ctx.PlainText(http.StatusOK, token) +} + +// VerifyUserGPGKey creates new GPG key to given user by ID. +func VerifyUserGPGKey(ctx *context.APIContext) { + // swagger:operation POST /user/gpg_key_verify user userVerifyGPGKey + // --- + // summary: Verify a GPG key + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // "201": + // "$ref": "#/responses/GPGKey" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.VerifyGPGKeyOption) + token := asymkey_model.VerificationToken(ctx.Doer, 1) + lastToken := asymkey_model.VerificationToken(ctx.Doer, 0) + + form.KeyID = strings.TrimLeft(form.KeyID, "0") + if form.KeyID == "" { + ctx.NotFound() + return + } + + _, err := asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, token, form.Signature) + if err != nil && asymkey_model.IsErrGPGInvalidTokenSignature(err) { + _, err = asymkey_model.VerifyGPGKey(ctx, ctx.Doer.ID, form.KeyID, lastToken, form.Signature) + } + + if err != nil { + if asymkey_model.IsErrGPGInvalidTokenSignature(err) { + ctx.Error(http.StatusUnprocessableEntity, "GPGInvalidSignature", fmt.Sprintf("The provided GPG key, signature and token do not match or token is out of date. Provide a valid signature for the token: %s", token)) + return + } + ctx.Error(http.StatusInternalServerError, "VerifyUserGPGKey", err) + } + + keys, err := db.Find[asymkey_model.GPGKey](ctx, asymkey_model.FindGPGKeyOptions{ + KeyID: form.KeyID, + IncludeSubKeys: true, + }) + if err != nil { + if asymkey_model.IsErrGPGKeyNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetGPGKeysByKeyID", err) + } + return + } + ctx.JSON(http.StatusOK, convert.ToGPGKey(keys[0])) +} + +// swagger:parameters userCurrentPostGPGKey +type swaggerUserCurrentPostGPGKey struct { + // in:body + Form api.CreateGPGKeyOption +} + +// CreateGPGKey create a GPG key belonging to the authenticated user +func CreateGPGKey(ctx *context.APIContext) { + // swagger:operation POST /user/gpg_keys user userCurrentPostGPGKey + // --- + // summary: Create a GPG key + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // "201": + // "$ref": "#/responses/GPGKey" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateGPGKeyOption) + CreateUserGPGKey(ctx, *form, ctx.Doer.ID) +} + +// DeleteGPGKey remove a GPG key belonging to the authenticated user +func DeleteGPGKey(ctx *context.APIContext) { + // swagger:operation DELETE /user/gpg_keys/{id} user userCurrentDeleteGPGKey + // --- + // summary: Remove a GPG key + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of key to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageGPGKeys) { + ctx.NotFound("Not Found", fmt.Errorf("gpg keys setting is not allowed to be visited")) + return + } + + if err := asymkey_model.DeleteGPGKey(ctx, ctx.Doer, ctx.ParamsInt64(":id")); err != nil { + if asymkey_model.IsErrGPGKeyAccessDenied(err) { + ctx.Error(http.StatusForbidden, "", "You do not have access to this key") + } else { + ctx.Error(http.StatusInternalServerError, "DeleteGPGKey", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} + +// HandleAddGPGKeyError handle add GPGKey error +func HandleAddGPGKeyError(ctx *context.APIContext, err error, token string) { + switch { + case asymkey_model.IsErrGPGKeyAccessDenied(err): + ctx.Error(http.StatusUnprocessableEntity, "GPGKeyAccessDenied", "You do not have access to this GPG key") + case asymkey_model.IsErrGPGKeyIDAlreadyUsed(err): + ctx.Error(http.StatusUnprocessableEntity, "GPGKeyIDAlreadyUsed", "A key with the same id already exists") + case asymkey_model.IsErrGPGKeyParsing(err): + ctx.Error(http.StatusUnprocessableEntity, "GPGKeyParsing", err) + case asymkey_model.IsErrGPGNoEmailFound(err): + ctx.Error(http.StatusNotFound, "GPGNoEmailFound", fmt.Sprintf("None of the emails attached to the GPG key could be found. It may still be added if you provide a valid signature for the token: %s", token)) + case asymkey_model.IsErrGPGInvalidTokenSignature(err): + ctx.Error(http.StatusUnprocessableEntity, "GPGInvalidSignature", fmt.Sprintf("The provided GPG key, signature and token do not match or token is out of date. Provide a valid signature for the token: %s", token)) + default: + ctx.Error(http.StatusInternalServerError, "AddGPGKey", err) + } +} diff --git a/routers/api/v1/user/helper.go b/routers/api/v1/user/helper.go new file mode 100644 index 0000000..8b5c64e --- /dev/null +++ b/routers/api/v1/user/helper.go @@ -0,0 +1,35 @@ +// Copyright 2021 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/context" +) + +// GetUserByParamsName get user by name +func GetUserByParamsName(ctx *context.APIContext, name string) *user_model.User { + username := ctx.Params(name) + user, err := user_model.GetUserByName(ctx, username) + if err != nil { + if user_model.IsErrUserNotExist(err) { + if redirectUserID, err2 := user_model.LookupUserRedirect(ctx, username); err2 == nil { + context.RedirectToUser(ctx.Base, username, redirectUserID) + } else { + ctx.NotFound("GetUserByName", err) + } + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err) + } + return nil + } + return user +} + +// GetUserByParams returns user whose name is presented in URL (":username"). +func GetUserByParams(ctx *context.APIContext) *user_model.User { + return GetUserByParamsName(ctx, ":username") +} diff --git a/routers/api/v1/user/hook.go b/routers/api/v1/user/hook.go new file mode 100644 index 0000000..9d9ca5b --- /dev/null +++ b/routers/api/v1/user/hook.go @@ -0,0 +1,159 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +// ListHooks list the authenticated user's webhooks +func ListHooks(ctx *context.APIContext) { + // swagger:operation GET /user/hooks user userListHooks + // --- + // summary: List the authenticated user's webhooks + // produces: + // - application/json + // parameters: + // - 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/HookList" + + utils.ListOwnerHooks( + ctx, + ctx.Doer, + ) +} + +// GetHook get the authenticated user's hook by id +func GetHook(ctx *context.APIContext) { + // swagger:operation GET /user/hooks/{id} user userGetHook + // --- + // summary: Get a hook + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Hook" + + hook, err := utils.GetOwnerHook(ctx, ctx.Doer.ID, ctx.ParamsInt64("id")) + if err != nil { + return + } + + if !ctx.Doer.IsAdmin && hook.OwnerID != ctx.Doer.ID { + ctx.NotFound() + return + } + + apiHook, err := webhook_service.ToHook(ctx.Doer.HomeLink(), hook) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusOK, apiHook) +} + +// CreateHook create a hook for the authenticated user +func CreateHook(ctx *context.APIContext) { + // swagger:operation POST /user/hooks user userCreateHook + // --- + // summary: Create a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateHookOption" + // responses: + // "201": + // "$ref": "#/responses/Hook" + + utils.AddOwnerHook( + ctx, + ctx.Doer, + web.GetForm(ctx).(*api.CreateHookOption), + ) +} + +// EditHook modify a hook of the authenticated user +func EditHook(ctx *context.APIContext) { + // swagger:operation PATCH /user/hooks/{id} user userEditHook + // --- + // summary: Update a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to update + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditHookOption" + // responses: + // "200": + // "$ref": "#/responses/Hook" + + utils.EditOwnerHook( + ctx, + ctx.Doer, + web.GetForm(ctx).(*api.EditHookOption), + ctx.ParamsInt64("id"), + ) +} + +// DeleteHook delete a hook of the authenticated user +func DeleteHook(ctx *context.APIContext) { + // swagger:operation DELETE /user/hooks/{id} user userDeleteHook + // --- + // summary: Delete a hook + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + utils.DeleteOwnerHook( + ctx, + ctx.Doer, + ctx.ParamsInt64("id"), + ) +} diff --git a/routers/api/v1/user/key.go b/routers/api/v1/user/key.go new file mode 100644 index 0000000..d9456e7 --- /dev/null +++ b/routers/api/v1/user/key.go @@ -0,0 +1,303 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + std_ctx "context" + "fmt" + "net/http" + + asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/perm" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/repo" + "code.gitea.io/gitea/routers/api/v1/utils" + asymkey_service "code.gitea.io/gitea/services/asymkey" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// appendPrivateInformation appends the owner and key type information to api.PublicKey +func appendPrivateInformation(ctx std_ctx.Context, apiKey *api.PublicKey, key *asymkey_model.PublicKey, defaultUser *user_model.User) (*api.PublicKey, error) { + if key.Type == asymkey_model.KeyTypeDeploy { + apiKey.KeyType = "deploy" + } else if key.Type == asymkey_model.KeyTypeUser { + apiKey.KeyType = "user" + + if defaultUser.ID == key.OwnerID { + apiKey.Owner = convert.ToUser(ctx, defaultUser, defaultUser) + } else { + user, err := user_model.GetUserByID(ctx, key.OwnerID) + if err != nil { + return apiKey, err + } + apiKey.Owner = convert.ToUser(ctx, user, user) + } + } else { + apiKey.KeyType = "unknown" + } + apiKey.ReadOnly = key.Mode == perm.AccessModeRead + return apiKey, nil +} + +func composePublicKeysAPILink() string { + return setting.AppURL + "api/v1/user/keys/" +} + +func listPublicKeys(ctx *context.APIContext, user *user_model.User) { + var keys []*asymkey_model.PublicKey + var err error + var count int + + fingerprint := ctx.FormString("fingerprint") + username := ctx.Params("username") + + if fingerprint != "" { + var userID int64 // Unrestricted + // Querying not just listing + if username != "" { + // Restrict to provided uid + userID = user.ID + } + keys, err = db.Find[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + OwnerID: userID, + Fingerprint: fingerprint, + }) + count = len(keys) + } else { + var total int64 + // Use ListPublicKeys + keys, total, err = db.FindAndCount[asymkey_model.PublicKey](ctx, asymkey_model.FindPublicKeyOptions{ + ListOptions: utils.GetListOptions(ctx), + OwnerID: user.ID, + NotKeytype: asymkey_model.KeyTypePrincipal, + }) + count = int(total) + } + + if err != nil { + ctx.Error(http.StatusInternalServerError, "ListPublicKeys", err) + return + } + + apiLink := composePublicKeysAPILink() + apiKeys := make([]*api.PublicKey, len(keys)) + for i := range keys { + apiKeys[i] = convert.ToPublicKey(apiLink, keys[i]) + if ctx.Doer.IsAdmin || ctx.Doer.ID == keys[i].OwnerID { + apiKeys[i], _ = appendPrivateInformation(ctx, apiKeys[i], keys[i], user) + } + } + + ctx.SetTotalCountHeader(int64(count)) + ctx.JSON(http.StatusOK, &apiKeys) +} + +// ListMyPublicKeys list all of the authenticated user's public keys +func ListMyPublicKeys(ctx *context.APIContext) { + // swagger:operation GET /user/keys user userCurrentListKeys + // --- + // summary: List the authenticated user's public keys + // parameters: + // - name: fingerprint + // in: query + // description: fingerprint of the key + // type: string + // - 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/PublicKeyList" + + listPublicKeys(ctx, ctx.Doer) +} + +// ListPublicKeys list the given user's public keys +func ListPublicKeys(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/keys user userListKeys + // --- + // summary: List the given user's public keys + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: fingerprint + // in: query + // description: fingerprint of the key + // type: string + // - 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/PublicKeyList" + // "404": + // "$ref": "#/responses/notFound" + + listPublicKeys(ctx, ctx.ContextUser) +} + +// GetPublicKey get a public key +func GetPublicKey(ctx *context.APIContext) { + // swagger:operation GET /user/keys/{id} user userCurrentGetKey + // --- + // summary: Get a public key + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of key to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/PublicKey" + // "404": + // "$ref": "#/responses/notFound" + + key, err := asymkey_model.GetPublicKeyByID(ctx, ctx.ParamsInt64(":id")) + if err != nil { + if asymkey_model.IsErrKeyNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetPublicKeyByID", err) + } + return + } + + apiLink := composePublicKeysAPILink() + apiKey := convert.ToPublicKey(apiLink, key) + if ctx.Doer.IsAdmin || ctx.Doer.ID == key.OwnerID { + apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Doer) + } + ctx.JSON(http.StatusOK, apiKey) +} + +// CreateUserPublicKey creates new public key to given user by ID. +func CreateUserPublicKey(ctx *context.APIContext, form api.CreateKeyOption, uid int64) { + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + + content, err := asymkey_model.CheckPublicKeyString(form.Key) + if err != nil { + repo.HandleCheckKeyStringError(ctx, err) + return + } + + key, err := asymkey_model.AddPublicKey(ctx, uid, form.Title, content, 0) + if err != nil { + repo.HandleAddKeyError(ctx, err) + return + } + apiLink := composePublicKeysAPILink() + apiKey := convert.ToPublicKey(apiLink, key) + if ctx.Doer.IsAdmin || ctx.Doer.ID == key.OwnerID { + apiKey, _ = appendPrivateInformation(ctx, apiKey, key, ctx.Doer) + } + ctx.JSON(http.StatusCreated, apiKey) +} + +// CreatePublicKey create one public key for me +func CreatePublicKey(ctx *context.APIContext) { + // swagger:operation POST /user/keys user userCurrentPostKey + // --- + // summary: Create a public key + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateKeyOption" + // responses: + // "201": + // "$ref": "#/responses/PublicKey" + // "422": + // "$ref": "#/responses/validationError" + + form := web.GetForm(ctx).(*api.CreateKeyOption) + CreateUserPublicKey(ctx, *form, ctx.Doer.ID) +} + +// DeletePublicKey delete one public key +func DeletePublicKey(ctx *context.APIContext) { + // swagger:operation DELETE /user/keys/{id} user userCurrentDeleteKey + // --- + // summary: Delete a public key + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of key to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if user_model.IsFeatureDisabledWithLoginType(ctx.Doer, setting.UserFeatureManageSSHKeys) { + ctx.NotFound("Not Found", fmt.Errorf("ssh keys setting is not allowed to be visited")) + return + } + + id := ctx.ParamsInt64(":id") + externallyManaged, err := asymkey_model.PublicKeyIsExternallyManaged(ctx, id) + if err != nil { + if asymkey_model.IsErrKeyNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "PublicKeyIsExternallyManaged", err) + } + return + } + + if externallyManaged { + ctx.Error(http.StatusForbidden, "", "SSH Key is externally managed for this user") + return + } + + if err := asymkey_service.DeletePublicKey(ctx, ctx.Doer, id); err != nil { + if asymkey_model.IsErrKeyAccessDenied(err) { + ctx.Error(http.StatusForbidden, "", "You do not have access to this key") + } else { + ctx.Error(http.StatusInternalServerError, "DeletePublicKey", err) + } + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/user/quota.go b/routers/api/v1/user/quota.go new file mode 100644 index 0000000..573d7b7 --- /dev/null +++ b/routers/api/v1/user/quota.go @@ -0,0 +1,118 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// GetQuota returns the quota information for the authenticated user +func GetQuota(ctx *context.APIContext) { + // swagger:operation GET /user/quota user userGetQuota + // --- + // summary: Get quota information for the authenticated user + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/QuotaInfo" + // "403": + // "$ref": "#/responses/forbidden" + + shared.GetQuota(ctx, ctx.Doer.ID) +} + +// CheckQuota returns whether the authenticated user is over the subject quota +func CheckQuota(ctx *context.APIContext) { + // swagger:operation GET /user/quota/check user userCheckQuota + // --- + // summary: Check if the authenticated user is over quota for a given subject + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/boolean" + // "403": + // "$ref": "#/responses/forbidden" + // "422": + // "$ref": "#/responses/validationError" + + shared.CheckQuota(ctx, ctx.Doer.ID) +} + +// ListQuotaAttachments lists attachments affecting the authenticated user's quota +func ListQuotaAttachments(ctx *context.APIContext) { + // swagger:operation GET /user/quota/attachments user userListQuotaAttachments + // --- + // summary: List the attachments affecting the authenticated user's quota + // produces: + // - application/json + // parameters: + // - 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/QuotaUsedAttachmentList" + // "403": + // "$ref": "#/responses/forbidden" + + shared.ListQuotaAttachments(ctx, ctx.Doer.ID) +} + +// ListQuotaPackages lists packages affecting the authenticated user's quota +func ListQuotaPackages(ctx *context.APIContext) { + // swagger:operation GET /user/quota/packages user userListQuotaPackages + // --- + // summary: List the packages affecting the authenticated user's quota + // produces: + // - application/json + // parameters: + // - 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/QuotaUsedPackageList" + // "403": + // "$ref": "#/responses/forbidden" + + shared.ListQuotaPackages(ctx, ctx.Doer.ID) +} + +// ListQuotaArtifacts lists artifacts affecting the authenticated user's quota +func ListQuotaArtifacts(ctx *context.APIContext) { + // swagger:operation GET /user/quota/artifacts user userListQuotaArtifacts + // --- + // summary: List the artifacts affecting the authenticated user's quota + // produces: + // - application/json + // parameters: + // - 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/QuotaUsedArtifactList" + // "403": + // "$ref": "#/responses/forbidden" + + shared.ListQuotaArtifacts(ctx, ctx.Doer.ID) +} diff --git a/routers/api/v1/user/repo.go b/routers/api/v1/user/repo.go new file mode 100644 index 0000000..86716ff --- /dev/null +++ b/routers/api/v1/user/repo.go @@ -0,0 +1,186 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// listUserRepos - List the repositories owned by the given user. +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{ + Actor: u, + Private: private, + ListOptions: opts, + OrderBy: "id ASC", + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepositories", err) + return + } + + if err := repos.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "RepositoryList.LoadAttributes", err) + return + } + + apiRepos := make([]*api.Repository, 0, len(repos)) + for i := range repos { + permission, err := access_model.GetUserRepoPermission(ctx, repos[i], ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + return + } + if ctx.IsSigned && ctx.Doer.IsAdmin || permission.HasAccess() { + apiRepos = append(apiRepos, convert.ToRepo(ctx, repos[i], permission)) + } + } + + ctx.SetLinkHeader(int(count), opts.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &apiRepos) +} + +// ListUserRepos - list the repos owned by the given user. +func ListUserRepos(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/repos user userListRepos + // --- + // summary: List the repos owned by the given user + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - 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/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" + + private := ctx.IsSigned + listUserRepos(ctx, ctx.ContextUser, private) +} + +// ListMyRepos - list the repositories you own or have access to. +func ListMyRepos(ctx *context.APIContext) { + // swagger:operation GET /user/repos user userCurrentListRepos + // --- + // summary: List the repos that the authenticated user owns + // produces: + // - application/json + // parameters: + // - 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 + // - name: order_by + // in: query + // description: order the repositories by name (default), id, or size + // type: string + // responses: + // "200": + // "$ref": "#/responses/RepositoryList" + // "422": + // "$ref": "#/responses/validationError" + + opts := &repo_model.SearchRepoOptions{ + ListOptions: utils.GetListOptions(ctx), + Actor: ctx.Doer, + OwnerID: ctx.Doer.ID, + Private: ctx.IsSigned, + IncludeDescription: true, + } + orderBy := ctx.FormTrim("order_by") + switch orderBy { + case "name": + opts.OrderBy = "name ASC" + case "size": + opts.OrderBy = "size DESC" + case "id": + opts.OrderBy = "id ASC" + case "": + default: + ctx.Error(http.StatusUnprocessableEntity, "", "invalid order_by") + return + } + + var err error + repos, count, err := repo_model.SearchRepository(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchRepository", err) + return + } + + results := make([]*api.Repository, len(repos)) + for i, repo := range repos { + if err = repo.LoadOwner(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadOwner", err) + return + } + permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserRepoPermission", err) + } + results[i] = convert.ToRepo(ctx, repo, permission) + } + + ctx.SetLinkHeader(int(count), opts.ListOptions.PageSize) + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, &results) +} + +// ListOrgRepos - list the repositories of an organization. +func ListOrgRepos(ctx *context.APIContext) { + // swagger:operation GET /orgs/{org}/repos organization orgListRepos + // --- + // summary: List an organization's repos + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - 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/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" + + listUserRepos(ctx, ctx.Org.Organization.AsUser(), ctx.IsSigned) +} diff --git a/routers/api/v1/user/runners.go b/routers/api/v1/user/runners.go new file mode 100644 index 0000000..8992184 --- /dev/null +++ b/routers/api/v1/user/runners.go @@ -0,0 +1,26 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "code.gitea.io/gitea/routers/api/v1/shared" + "code.gitea.io/gitea/services/context" +) + +// https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#create-a-registration-token-for-an-organization + +// GetRegistrationToken returns the token to register user runners +func GetRegistrationToken(ctx *context.APIContext) { + // swagger:operation GET /user/actions/runners/registration-token user userGetRunnerRegistrationToken + // --- + // summary: Get an user's actions runner registration token + // produces: + // - application/json + // parameters: + // responses: + // "200": + // "$ref": "#/responses/RegistrationToken" + + shared.GetRegistrationToken(ctx, ctx.Doer.ID, 0) +} diff --git a/routers/api/v1/user/settings.go b/routers/api/v1/user/settings.go new file mode 100644 index 0000000..bfd2401 --- /dev/null +++ b/routers/api/v1/user/settings.go @@ -0,0 +1,67 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + user_service "code.gitea.io/gitea/services/user" +) + +// GetUserSettings returns user settings +func GetUserSettings(ctx *context.APIContext) { + // swagger:operation GET /user/settings user getUserSettings + // --- + // summary: Get user settings + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/UserSettings" + ctx.JSON(http.StatusOK, convert.User2UserSettings(ctx.Doer)) +} + +// UpdateUserSettings returns user settings +func UpdateUserSettings(ctx *context.APIContext) { + // swagger:operation PATCH /user/settings user updateUserSettings + // --- + // summary: Update user settings + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UserSettingsOptions" + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/UserSettings" + + form := web.GetForm(ctx).(*api.UserSettingsOptions) + + opts := &user_service.UpdateOptions{ + FullName: optional.FromPtr(form.FullName), + Description: optional.FromPtr(form.Description), + Pronouns: optional.FromPtr(form.Pronouns), + Website: optional.FromPtr(form.Website), + Location: optional.FromPtr(form.Location), + Language: optional.FromPtr(form.Language), + Theme: optional.FromPtr(form.Theme), + DiffViewStyle: optional.FromPtr(form.DiffViewStyle), + KeepEmailPrivate: optional.FromPtr(form.HideEmail), + KeepActivityPrivate: optional.FromPtr(form.HideActivity), + EnableRepoUnitHints: optional.FromPtr(form.EnableRepoUnitHints), + } + if err := user_service.UpdateUser(ctx, ctx.Doer, opts); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.JSON(http.StatusOK, convert.User2UserSettings(ctx.Doer)) +} diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go new file mode 100644 index 0000000..cb9e05f --- /dev/null +++ b/routers/api/v1/user/star.go @@ -0,0 +1,197 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// Copyright 2024 The Forgejo Authors. +// SPDX-License-Identifier: MIT + +package user + +import ( + std_context "context" + "net/http" + + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/repository" +) + +// getStarredRepos returns the repos that the user with the specified userID has +// starred +func getStarredRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, error) { + starredRepos, err := repo_model.GetStarredRepos(ctx, user.ID, private, listOptions) + if err != nil { + return nil, err + } + + repos := make([]*api.Repository, len(starredRepos)) + for i, starred := range starredRepos { + permission, err := access_model.GetUserRepoPermission(ctx, starred, user) + if err != nil { + return nil, err + } + repos[i] = convert.ToRepo(ctx, starred, permission) + } + return repos, nil +} + +// GetStarredRepos returns the repos that the given user has starred +func GetStarredRepos(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/starred user userListStarred + // --- + // summary: The repos that the given user has starred + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - 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/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" + + private := ctx.ContextUser.ID == ctx.Doer.ID + repos, err := getStarredRepos(ctx, ctx.ContextUser, private, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "getStarredRepos", err) + return + } + + ctx.SetTotalCountHeader(int64(ctx.ContextUser.NumStars)) + ctx.JSON(http.StatusOK, &repos) +} + +// GetMyStarredRepos returns the repos that the authenticated user has starred +func GetMyStarredRepos(ctx *context.APIContext) { + // swagger:operation GET /user/starred user userCurrentListStarred + // --- + // summary: The repos that the authenticated user has starred + // parameters: + // - 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/RepositoryList" + + repos, err := getStarredRepos(ctx, ctx.Doer, true, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "getStarredRepos", err) + } + + ctx.SetTotalCountHeader(int64(ctx.Doer.NumStars)) + ctx.JSON(http.StatusOK, &repos) +} + +// IsStarring returns whether the authenticated is starring the repo +func IsStarring(ctx *context.APIContext) { + // swagger:operation GET /user/starred/{owner}/{repo} user userCurrentCheckStarring + // --- + // summary: Whether the authenticated is starring the repo + // 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: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + if repo_model.IsStaring(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { + ctx.Status(http.StatusNoContent) + } else { + ctx.NotFound() + } +} + +// Star the repo specified in the APIContext, as the authenticated user +func Star(ctx *context.APIContext) { + // swagger:operation PUT /user/starred/{owner}/{repo} user userCurrentPutStar + // --- + // summary: Star the given repo + // parameters: + // - name: owner + // in: path + // description: owner of the repo to star + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to star + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + err := repository.StarRepoAndSendLikeActivities(ctx, *ctx.Doer, ctx.Repo.Repository.ID, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "StarRepo", err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// Unstar the repo specified in the APIContext, as the authenticated user +func Unstar(ctx *context.APIContext) { + // swagger:operation DELETE /user/starred/{owner}/{repo} user userCurrentDeleteStar + // --- + // summary: Unstar the given repo + // parameters: + // - name: owner + // in: path + // description: owner of the repo to unstar + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo to unstar + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + err := repository.StarRepoAndSendLikeActivities(ctx, *ctx.Doer, ctx.Repo.Repository.ID, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "StarRepo", err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go new file mode 100644 index 0000000..4efcb7c --- /dev/null +++ b/routers/api/v1/user/user.go @@ -0,0 +1,306 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package user + +import ( + "fmt" + "net/http" + + activities_model "code.gitea.io/gitea/models/activities" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// Search search users +func Search(ctx *context.APIContext) { + // swagger:operation GET /users/search user userSearch + // --- + // summary: Search for users + // produces: + // - application/json + // parameters: + // - name: q + // in: query + // description: keyword + // type: string + // - name: uid + // in: query + // description: ID of the user to search for + // type: integer + // format: int64 + // - 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": + // description: "SearchResults of a successful search" + // schema: + // type: object + // properties: + // ok: + // type: boolean + // data: + // type: array + // items: + // "$ref": "#/definitions/User" + + listOptions := utils.GetListOptions(ctx) + + uid := ctx.FormInt64("uid") + var users []*user_model.User + var maxResults int64 + var err error + + switch uid { + case user_model.GhostUserID: + maxResults = 1 + users = []*user_model.User{user_model.NewGhostUser()} + case user_model.ActionsUserID: + maxResults = 1 + users = []*user_model.User{user_model.NewActionsUser()} + default: + var visible []structs.VisibleType + if ctx.PublicOnly { + visible = []structs.VisibleType{structs.VisibleTypePublic} + } + users, maxResults, err = user_model.SearchUsers(ctx, &user_model.SearchUserOptions{ + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + UID: uid, + Type: user_model.UserTypeIndividual, + Visible: visible, + ListOptions: listOptions, + }) + if err != nil { + ctx.JSON(http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": err.Error(), + }) + return + } + } + + ctx.SetLinkHeader(int(maxResults), listOptions.PageSize) + ctx.SetTotalCountHeader(maxResults) + + ctx.JSON(http.StatusOK, map[string]any{ + "ok": true, + "data": convert.ToUsers(ctx, ctx.Doer, users), + }) +} + +// GetInfo get user's information +func GetInfo(ctx *context.APIContext) { + // swagger:operation GET /users/{username} user userGet + // --- + // summary: Get a user + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/User" + // "404": + // "$ref": "#/responses/notFound" + + if !user_model.IsUserVisibleToViewer(ctx, ctx.ContextUser, ctx.Doer) { + // fake ErrUserNotExist error message to not leak information about existence + ctx.NotFound("GetUserByName", user_model.ErrUserNotExist{Name: ctx.Params(":username")}) + return + } + ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.ContextUser, ctx.Doer)) +} + +// GetAuthenticatedUser get current user's information +func GetAuthenticatedUser(ctx *context.APIContext) { + // swagger:operation GET /user user userGetCurrent + // --- + // summary: Get the authenticated user + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/User" + + ctx.JSON(http.StatusOK, convert.ToUser(ctx, ctx.Doer, ctx.Doer)) +} + +// GetUserHeatmapData is the handler to get a users heatmap +func GetUserHeatmapData(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/heatmap user userGetHeatmapData + // --- + // summary: Get a user's heatmap + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user to get + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/UserHeatmapData" + // "404": + // "$ref": "#/responses/notFound" + + heatmap, err := activities_model.GetUserHeatmapDataByUser(ctx, ctx.ContextUser, ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetUserHeatmapDataByUser", err) + return + } + ctx.JSON(http.StatusOK, heatmap) +} + +func ListUserActivityFeeds(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/activities/feeds user userListActivityFeeds + // --- + // summary: List a user's activity feeds + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of user + // type: string + // required: true + // - name: only-performed-by + // in: query + // description: if true, only show actions performed by the requested user + // type: boolean + // - name: date + // in: query + // description: the date of the activities to be found + // type: string + // format: date + // - 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/ActivityFeedsList" + // "404": + // "$ref": "#/responses/notFound" + + includePrivate := ctx.IsSigned && (ctx.Doer.IsAdmin || ctx.Doer.ID == ctx.ContextUser.ID) + listOptions := utils.GetListOptions(ctx) + + opts := activities_model.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.Doer, + IncludePrivate: includePrivate, + OnlyPerformedBy: ctx.FormBool("only-performed-by"), + Date: ctx.FormString("date"), + ListOptions: listOptions, + } + + feeds, count, err := activities_model.GetFeeds(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + ctx.JSON(http.StatusOK, convert.ToActivities(ctx, feeds, ctx.Doer)) +} + +// ListBlockedUsers list the authenticated user's blocked users. +func ListBlockedUsers(ctx *context.APIContext) { + // swagger:operation GET /user/list_blocked user userListBlockedUsers + // --- + // summary: List the authenticated user's blocked users + // produces: + // - application/json + // parameters: + // - 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/BlockedUserList" + + utils.ListUserBlockedUsers(ctx, ctx.Doer) +} + +// BlockUser blocks a user from the doer. +func BlockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/block/{username} user userBlockUser + // --- + // summary: Blocks a user from the doer. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.BlockUser(ctx, ctx.Doer, ctx.ContextUser) +} + +// UnblockUser unblocks a user from the doer. +func UnblockUser(ctx *context.APIContext) { + // swagger:operation PUT /user/unblock/{username} user userUnblockUser + // --- + // summary: Unblocks a user from the doer. + // produces: + // - application/json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + // "422": + // "$ref": "#/responses/validationError" + + if ctx.ContextUser.IsOrganization() { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Errorf("%s is an organization not a user", ctx.ContextUser.Name)) + return + } + + utils.UnblockUser(ctx, ctx.Doer, ctx.ContextUser) +} diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go new file mode 100644 index 0000000..706f4cc --- /dev/null +++ b/routers/api/v1/user/watch.go @@ -0,0 +1,211 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + std_context "context" + "net/http" + + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// getWatchedRepos returns the repos that the user with the specified userID is watching +func getWatchedRepos(ctx std_context.Context, user *user_model.User, private bool, listOptions db.ListOptions) ([]*api.Repository, int64, error) { + watchedRepos, total, err := repo_model.GetWatchedRepos(ctx, user.ID, private, listOptions) + if err != nil { + return nil, 0, err + } + + repos := make([]*api.Repository, len(watchedRepos)) + for i, watched := range watchedRepos { + permission, err := access_model.GetUserRepoPermission(ctx, watched, user) + if err != nil { + return nil, 0, err + } + repos[i] = convert.ToRepo(ctx, watched, permission) + } + return repos, total, nil +} + +// GetWatchedRepos returns the repos that the user specified in ctx is watching +func GetWatchedRepos(ctx *context.APIContext) { + // swagger:operation GET /users/{username}/subscriptions user userListSubscriptions + // --- + // summary: List the repositories watched by a user + // produces: + // - application/json + // parameters: + // - name: username + // type: string + // in: path + // description: username of the user + // required: true + // - 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/RepositoryList" + // "404": + // "$ref": "#/responses/notFound" + + private := ctx.ContextUser.ID == ctx.Doer.ID + repos, total, err := getWatchedRepos(ctx, ctx.ContextUser, private, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "getWatchedRepos", err) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, &repos) +} + +// GetMyWatchedRepos returns the repos that the authenticated user is watching +func GetMyWatchedRepos(ctx *context.APIContext) { + // swagger:operation GET /user/subscriptions user userCurrentListSubscriptions + // --- + // summary: List repositories watched by the authenticated user + // produces: + // - application/json + // parameters: + // - 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/RepositoryList" + + repos, total, err := getWatchedRepos(ctx, ctx.Doer, true, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "getWatchedRepos", err) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, &repos) +} + +// IsWatching returns whether the authenticated user is watching the repo +// specified in ctx +func IsWatching(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/subscription repository userCurrentCheckSubscription + // --- + // summary: Check if the current user is watching a repo + // 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": + // "$ref": "#/responses/WatchInfo" + // "404": + // description: User is not watching this repo or repo do not exist + + if repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { + ctx.JSON(http.StatusOK, api.WatchInfo{ + Subscribed: true, + Ignored: false, + Reason: nil, + CreatedAt: ctx.Repo.Repository.CreatedUnix.AsTime(), + URL: subscriptionURL(ctx.Repo.Repository), + RepositoryURL: ctx.Repo.Repository.APIURL(), + }) + } else { + ctx.NotFound() + } +} + +// Watch the repo specified in ctx, as the authenticated user +func Watch(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/subscription repository userCurrentPutSubscription + // --- + // summary: Watch a repo + // 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": + // "$ref": "#/responses/WatchInfo" + // "404": + // "$ref": "#/responses/notFound" + + err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "WatchRepo", err) + return + } + ctx.JSON(http.StatusOK, api.WatchInfo{ + Subscribed: true, + Ignored: false, + Reason: nil, + CreatedAt: ctx.Repo.Repository.CreatedUnix.AsTime(), + URL: subscriptionURL(ctx.Repo.Repository), + RepositoryURL: ctx.Repo.Repository.APIURL(), + }) +} + +// Unwatch the repo specified in ctx, as the authenticated user +func Unwatch(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/subscription repository userCurrentDeleteSubscription + // --- + // summary: Unwatch a repo + // 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: + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + err := repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UnwatchRepo", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// subscriptionURL returns the URL of the subscription API endpoint of a repo +func subscriptionURL(repo *repo_model.Repository) string { + return repo.APIURL() + "/subscription" +} diff --git a/routers/api/v1/utils/block.go b/routers/api/v1/utils/block.go new file mode 100644 index 0000000..34fad96 --- /dev/null +++ b/routers/api/v1/utils/block.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + user_service "code.gitea.io/gitea/services/user" +) + +// ListUserBlockedUsers lists the blocked users of the provided doer. +func ListUserBlockedUsers(ctx *context.APIContext, doer *user_model.User) { + count, err := user_model.CountBlockedUsers(ctx, doer.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + blockedUsers, err := user_model.ListBlockedUsers(ctx, doer.ID, GetListOptions(ctx)) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiBlockedUsers := make([]*api.BlockedUser, len(blockedUsers)) + for i, blockedUser := range blockedUsers { + apiBlockedUsers[i] = &api.BlockedUser{ + BlockID: blockedUser.ID, + Created: blockedUser.CreatedUnix.AsTime(), + } + if err != nil { + ctx.InternalServerError(err) + return + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiBlockedUsers) +} + +// BlockUser blocks the blockUser from the doer. +func BlockUser(ctx *context.APIContext, doer, blockUser *user_model.User) { + err := user_service.BlockUser(ctx, doer.ID, blockUser.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// UnblockUser unblocks the blockUser from the doer. +func UnblockUser(ctx *context.APIContext, doer, blockUser *user_model.User) { + err := user_model.UnblockUser(ctx, doer.ID, blockUser.ID) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/utils/git.go b/routers/api/v1/utils/git.go new file mode 100644 index 0000000..4e25137 --- /dev/null +++ b/routers/api/v1/utils/git.go @@ -0,0 +1,99 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + gocontext "context" + "fmt" + "net/http" + + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" +) + +// ResolveRefOrSha resolve ref to sha if exist +func ResolveRefOrSha(ctx *context.APIContext, ref string) string { + if len(ref) == 0 { + ctx.Error(http.StatusBadRequest, "ref not given", nil) + return "" + } + + sha := ref + // Search branches and tags + for _, refType := range []string{"heads", "tags"} { + refSHA, lastMethodName, err := searchRefCommitByType(ctx, refType, ref) + if err != nil { + ctx.Error(http.StatusInternalServerError, lastMethodName, err) + return "" + } + if refSHA != "" { + sha = refSHA + break + } + } + + sha = MustConvertToSHA1(ctx, ctx.Repo, sha) + + if ctx.Repo.GitRepo != nil { + err := ctx.Repo.GitRepo.AddLastCommitCache(ctx.Repo.Repository.GetCommitsCountCacheKey(ref, ref != sha), ctx.Repo.Repository.FullName(), sha) + if err != nil { + log.Error("Unable to get commits count for %s in %s. Error: %v", sha, ctx.Repo.Repository.FullName(), err) + } + } + + return sha +} + +// GetGitRefs return git references based on filter +func GetGitRefs(ctx *context.APIContext, filter string) ([]*git.Reference, string, error) { + if ctx.Repo.GitRepo == nil { + return nil, "", fmt.Errorf("no open git repo found in context") + } + if len(filter) > 0 { + filter = "refs/" + filter + } + refs, err := ctx.Repo.GitRepo.GetRefsFiltered(filter) + return refs, "GetRefsFiltered", err +} + +func searchRefCommitByType(ctx *context.APIContext, refType, filter string) (string, string, error) { + refs, lastMethodName, err := GetGitRefs(ctx, refType+"/"+filter) // Search by type + if err != nil { + return "", lastMethodName, err + } + if len(refs) > 0 { + return refs[0].Object.String(), "", nil // Return found SHA + } + return "", "", nil +} + +// ConvertToObjectID returns a full-length SHA1 from a potential ID string +func ConvertToObjectID(ctx gocontext.Context, repo *context.Repository, commitID string) (git.ObjectID, error) { + objectFormat := repo.GetObjectFormat() + if len(commitID) == objectFormat.FullLength() && objectFormat.IsValid(commitID) { + sha, err := git.NewIDFromString(commitID) + if err == nil { + return sha, nil + } + } + + gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo.Repository) + if err != nil { + return objectFormat.EmptyObjectID(), fmt.Errorf("RepositoryFromContextOrOpen: %w", err) + } + defer closer.Close() + + return gitRepo.ConvertToGitID(commitID) +} + +// MustConvertToSHA1 returns a full-length SHA1 string from a potential ID string, or returns origin input if it can't convert to SHA1 +func MustConvertToSHA1(ctx gocontext.Context, repo *context.Repository, commitID string) string { + sha, err := ConvertToObjectID(ctx, repo, commitID) + if err != nil { + return commitID + } + return sha.String() +} diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go new file mode 100644 index 0000000..f1abd49 --- /dev/null +++ b/routers/api/v1/utils/hook.go @@ -0,0 +1,419 @@ +// Copyright 2016 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" + webhook_module "code.gitea.io/gitea/modules/webhook" + "code.gitea.io/gitea/services/context" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +// ListOwnerHooks lists the webhooks of the provided owner +func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) { + opts := &webhook.ListWebhookOptions{ + ListOptions: GetListOptions(ctx), + OwnerID: owner.ID, + } + + hooks, count, err := db.FindAndCount[webhook.Webhook](ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiHooks := make([]*api.Hook, len(hooks)) + for i, hook := range hooks { + apiHooks[i], err = webhook_service.ToHook(owner.HomeLink(), hook) + if err != nil { + ctx.InternalServerError(err) + return + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiHooks) +} + +// GetOwnerHook gets an user or organization webhook. Errors are written to ctx. +func GetOwnerHook(ctx *context.APIContext, ownerID, hookID int64) (*webhook.Webhook, error) { + w, err := webhook.GetWebhookByOwnerID(ctx, ownerID, hookID) + if err != nil { + if webhook.IsErrWebhookNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetWebhookByOwnerID", err) + } + return nil, err + } + return w, nil +} + +// GetRepoHook get a repo's webhook. If there is an error, write to `ctx` +// accordingly and return the error +func GetRepoHook(ctx *context.APIContext, repoID, hookID int64) (*webhook.Webhook, error) { + w, err := webhook.GetWebhookByRepoID(ctx, repoID, hookID) + if err != nil { + if webhook.IsErrWebhookNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "GetWebhookByID", err) + } + return nil, err + } + return w, nil +} + +// checkCreateHookOption check if a CreateHookOption form is valid. If invalid, +// write the appropriate error to `ctx`. Return whether the form is valid +func checkCreateHookOption(ctx *context.APIContext, form *api.CreateHookOption) bool { + if !webhook_service.IsValidHookTaskType(form.Type) { + ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Invalid hook type: %s", form.Type)) + return false + } + for _, name := range []string{"url", "content_type"} { + if _, ok := form.Config[name]; !ok { + ctx.Error(http.StatusUnprocessableEntity, "", "Missing config option: "+name) + return false + } + } + if !webhook.IsValidHookContentType(form.Config["content_type"]) { + ctx.Error(http.StatusUnprocessableEntity, "", "Invalid content type") + return false + } + return true +} + +// AddSystemHook add a system hook +func AddSystemHook(ctx *context.APIContext, form *api.CreateHookOption) { + hook, ok := addHook(ctx, form, 0, 0) + if ok { + h, err := webhook_service.ToHook(setting.AppSubURL+"/admin", hook) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToHook", err) + return + } + ctx.JSON(http.StatusCreated, h) + } +} + +// AddOwnerHook adds a hook to an user or organization +func AddOwnerHook(ctx *context.APIContext, owner *user_model.User, form *api.CreateHookOption) { + hook, ok := addHook(ctx, form, owner.ID, 0) + if !ok { + return + } + apiHook, ok := toAPIHook(ctx, owner.HomeLink(), hook) + if !ok { + return + } + ctx.JSON(http.StatusCreated, apiHook) +} + +// AddRepoHook add a hook to a repo. Writes to `ctx` accordingly +func AddRepoHook(ctx *context.APIContext, form *api.CreateHookOption) { + repo := ctx.Repo + hook, ok := addHook(ctx, form, 0, repo.Repository.ID) + if !ok { + return + } + apiHook, ok := toAPIHook(ctx, repo.RepoLink, hook) + if !ok { + return + } + ctx.JSON(http.StatusCreated, apiHook) +} + +// toAPIHook converts the hook to its API representation. +// If there is an error, write to `ctx` accordingly. Return (hook, ok) +func toAPIHook(ctx *context.APIContext, repoLink string, hook *webhook.Webhook) (*api.Hook, bool) { + apiHook, err := webhook_service.ToHook(repoLink, hook) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ToHook", err) + return nil, false + } + return apiHook, true +} + +func issuesHook(events []string, event string) bool { + return util.SliceContainsString(events, event, true) || util.SliceContainsString(events, string(webhook_module.HookEventIssues), true) +} + +func pullHook(events []string, event string) bool { + return util.SliceContainsString(events, event, true) || util.SliceContainsString(events, string(webhook_module.HookEventPullRequest), true) +} + +// 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) { + var isSystemWebhook bool + if !checkCreateHookOption(ctx, form) { + 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 { + ctx.Error(http.StatusUnprocessableEntity, "", "Invalid is_system_webhook value") + return nil, false + } + isSystemWebhook = sw + } + w := &webhook.Webhook{ + OwnerID: ownerID, + RepoID: repoID, + URL: form.Config["url"], + ContentType: webhook.ToHookContentType(form.Config["content_type"]), + Secret: form.Config["secret"], + HTTPMethod: "POST", + IsSystemWebhook: isSystemWebhook, + HookEvent: &webhook_module.HookEvent{ + ChooseEvents: true, + HookEvents: webhook_module.HookEvents{ + Create: util.SliceContainsString(form.Events, string(webhook_module.HookEventCreate), true), + Delete: util.SliceContainsString(form.Events, string(webhook_module.HookEventDelete), true), + Fork: util.SliceContainsString(form.Events, string(webhook_module.HookEventFork), true), + Issues: issuesHook(form.Events, "issues_only"), + IssueAssign: issuesHook(form.Events, string(webhook_module.HookEventIssueAssign)), + IssueLabel: issuesHook(form.Events, string(webhook_module.HookEventIssueLabel)), + IssueMilestone: issuesHook(form.Events, string(webhook_module.HookEventIssueMilestone)), + IssueComment: issuesHook(form.Events, string(webhook_module.HookEventIssueComment)), + Push: util.SliceContainsString(form.Events, string(webhook_module.HookEventPush), true), + PullRequest: pullHook(form.Events, "pull_request_only"), + PullRequestAssign: pullHook(form.Events, string(webhook_module.HookEventPullRequestAssign)), + PullRequestLabel: pullHook(form.Events, string(webhook_module.HookEventPullRequestLabel)), + PullRequestMilestone: pullHook(form.Events, string(webhook_module.HookEventPullRequestMilestone)), + PullRequestComment: pullHook(form.Events, string(webhook_module.HookEventPullRequestComment)), + PullRequestReview: pullHook(form.Events, "pull_request_review"), + PullRequestReviewRequest: pullHook(form.Events, string(webhook_module.HookEventPullRequestReviewRequest)), + PullRequestSync: pullHook(form.Events, string(webhook_module.HookEventPullRequestSync)), + Wiki: util.SliceContainsString(form.Events, string(webhook_module.HookEventWiki), true), + Repository: util.SliceContainsString(form.Events, string(webhook_module.HookEventRepository), true), + Release: util.SliceContainsString(form.Events, string(webhook_module.HookEventRelease), true), + }, + BranchFilter: form.BranchFilter, + }, + IsActive: form.Active, + Type: form.Type, + } + err := w.SetHeaderAuthorization(form.AuthorizationHeader) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SetHeaderAuthorization", err) + return nil, false + } + if w.Type == webhook_module.SLACK { + channel, ok := form.Config["channel"] + if !ok { + ctx.Error(http.StatusUnprocessableEntity, "", "Missing config option: channel") + return nil, false + } + channel = strings.TrimSpace(channel) + + if !webhook_service.IsValidSlackChannel(channel) { + ctx.Error(http.StatusBadRequest, "", "Invalid slack channel name") + return nil, false + } + + meta, err := json.Marshal(&webhook_service.SlackMeta{ + Channel: channel, + Username: form.Config["username"], + IconURL: form.Config["icon_url"], + Color: form.Config["color"], + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "slack: JSON marshal failed", err) + return nil, false + } + w.Meta = string(meta) + } + + if err := w.UpdateEvent(); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateEvent", err) + return nil, false + } else if err := webhook.CreateWebhook(ctx, w); err != nil { + ctx.Error(http.StatusInternalServerError, "CreateWebhook", err) + return nil, false + } + return w, true +} + +// EditSystemHook edit system webhook `w` according to `form`. Writes to `ctx` accordingly +func EditSystemHook(ctx *context.APIContext, form *api.EditHookOption, hookID int64) { + hook, err := webhook.GetSystemOrDefaultWebhook(ctx, hookID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetSystemOrDefaultWebhook", err) + return + } + if !editHook(ctx, form, hook) { + ctx.Error(http.StatusInternalServerError, "editHook", err) + return + } + updated, err := webhook.GetSystemOrDefaultWebhook(ctx, hookID) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetSystemOrDefaultWebhook", err) + return + } + h, err := webhook_service.ToHook(setting.AppURL+"/admin", updated) + if err != nil { + ctx.Error(http.StatusInternalServerError, "convert.ToHook", err) + return + } + ctx.JSON(http.StatusOK, h) +} + +// EditOwnerHook updates a webhook of an user or organization +func EditOwnerHook(ctx *context.APIContext, owner *user_model.User, form *api.EditHookOption, hookID int64) { + hook, err := GetOwnerHook(ctx, owner.ID, hookID) + if err != nil { + return + } + if !editHook(ctx, form, hook) { + return + } + updated, err := GetOwnerHook(ctx, owner.ID, hookID) + if err != nil { + return + } + apiHook, ok := toAPIHook(ctx, owner.HomeLink(), updated) + if !ok { + return + } + ctx.JSON(http.StatusOK, apiHook) +} + +// EditRepoHook edit webhook `w` according to `form`. Writes to `ctx` accordingly +func EditRepoHook(ctx *context.APIContext, form *api.EditHookOption, hookID int64) { + repo := ctx.Repo + hook, err := GetRepoHook(ctx, repo.Repository.ID, hookID) + if err != nil { + return + } + if !editHook(ctx, form, hook) { + return + } + updated, err := GetRepoHook(ctx, repo.Repository.ID, hookID) + if err != nil { + return + } + apiHook, ok := toAPIHook(ctx, repo.RepoLink, updated) + if !ok { + return + } + ctx.JSON(http.StatusOK, apiHook) +} + +// editHook edit the webhook `w` according to `form`. If an error occurs, write +// to `ctx` accordingly and return the error. Return whether successful +func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webhook) bool { + if form.Config != nil { + if url, ok := form.Config["url"]; ok { + w.URL = url + } + if ct, ok := form.Config["content_type"]; ok { + if !webhook.IsValidHookContentType(ct) { + ctx.Error(http.StatusUnprocessableEntity, "", "Invalid content type") + return false + } + w.ContentType = webhook.ToHookContentType(ct) + } + + if w.Type == webhook_module.SLACK { + if channel, ok := form.Config["channel"]; ok { + meta, err := json.Marshal(&webhook_service.SlackMeta{ + Channel: channel, + Username: form.Config["username"], + IconURL: form.Config["icon_url"], + Color: form.Config["color"], + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, "slack: JSON marshal failed", err) + return false + } + w.Meta = string(meta) + } + } + } + + // Update events + if len(form.Events) == 0 { + form.Events = []string{"push"} + } + w.PushOnly = false + w.SendEverything = false + w.ChooseEvents = true + w.Create = util.SliceContainsString(form.Events, string(webhook_module.HookEventCreate), true) + w.Push = util.SliceContainsString(form.Events, string(webhook_module.HookEventPush), true) + w.Create = util.SliceContainsString(form.Events, string(webhook_module.HookEventCreate), true) + w.Delete = util.SliceContainsString(form.Events, string(webhook_module.HookEventDelete), true) + w.Fork = util.SliceContainsString(form.Events, string(webhook_module.HookEventFork), true) + w.Repository = util.SliceContainsString(form.Events, string(webhook_module.HookEventRepository), true) + w.Wiki = util.SliceContainsString(form.Events, string(webhook_module.HookEventWiki), true) + w.Release = util.SliceContainsString(form.Events, string(webhook_module.HookEventRelease), true) + w.BranchFilter = form.BranchFilter + + err := w.SetHeaderAuthorization(form.AuthorizationHeader) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SetHeaderAuthorization", err) + return false + } + + // Issues + w.Issues = issuesHook(form.Events, "issues_only") + w.IssueAssign = issuesHook(form.Events, string(webhook_module.HookEventIssueAssign)) + w.IssueLabel = issuesHook(form.Events, string(webhook_module.HookEventIssueLabel)) + w.IssueMilestone = issuesHook(form.Events, string(webhook_module.HookEventIssueMilestone)) + w.IssueComment = issuesHook(form.Events, string(webhook_module.HookEventIssueComment)) + + // Pull requests + w.PullRequest = pullHook(form.Events, "pull_request_only") + w.PullRequestAssign = pullHook(form.Events, string(webhook_module.HookEventPullRequestAssign)) + w.PullRequestLabel = pullHook(form.Events, string(webhook_module.HookEventPullRequestLabel)) + w.PullRequestMilestone = pullHook(form.Events, string(webhook_module.HookEventPullRequestMilestone)) + w.PullRequestComment = pullHook(form.Events, string(webhook_module.HookEventPullRequestComment)) + w.PullRequestReview = pullHook(form.Events, "pull_request_review") + w.PullRequestReviewRequest = pullHook(form.Events, string(webhook_module.HookEventPullRequestReviewRequest)) + w.PullRequestSync = pullHook(form.Events, string(webhook_module.HookEventPullRequestSync)) + + if err := w.UpdateEvent(); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateEvent", err) + return false + } + + if form.Active != nil { + w.IsActive = *form.Active + } + + if err := webhook.UpdateWebhook(ctx, w); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateWebhook", err) + return false + } + return true +} + +// DeleteOwnerHook deletes the hook owned by the owner. +func DeleteOwnerHook(ctx *context.APIContext, owner *user_model.User, hookID int64) { + if err := webhook.DeleteWebhookByOwnerID(ctx, owner.ID, hookID); err != nil { + if webhook.IsErrWebhookNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "DeleteWebhookByOwnerID", err) + } + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/utils/page.go b/routers/api/v1/utils/page.go new file mode 100644 index 0000000..024ba7b --- /dev/null +++ b/routers/api/v1/utils/page.go @@ -0,0 +1,18 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// GetListOptions returns list options using the page and limit parameters +func GetListOptions(ctx *context.APIContext) db.ListOptions { + return db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } +} -- cgit v1.2.3